Browse Source

视频付费功能上线前的稳定版本

npzzwq99 2 weeks ago
commit
0afab930a6
100 changed files with 21072 additions and 0 deletions
  1. 17 0
      .gitignore
  2. 41 0
      App.vue
  3. 21 0
      LICENSE
  4. 15 0
      README.md
  5. 55 0
      bootstrap/i18n-factory.js
  6. 55 0
      bootstrap/launcher.js
  7. 10 0
      changelog.md
  8. 1 0
      cli-help.txt
  9. 21 0
      common/chat-utils.js
  10. 121 0
      common/config/runtime-manifest.js
  11. 52 0
      common/mixins/resend-cooldown.js
  12. 260 0
      components/address-picker/address-picker.vue
  13. 228 0
      components/app-tabbar/app-tabbar.vue
  14. 262 0
      components/app-update-sheet/app-update-sheet.vue
  15. 491 0
      components/dynamics/detail-comment-panel.vue
  16. 752 0
      components/dynamics/find-feed-list.vue
  17. 171 0
      components/dynamics/post-card.vue
  18. 244 0
      components/dynamics/post-content-block.vue
  19. 137 0
      components/dynamics/post-image-grid.vue
  20. 46 0
      components/empty/empty.vue
  21. 618 0
      components/feed-comment/comment-thread.vue
  22. 227 0
      components/feed-reel/media-cell.vue
  23. 1362 0
      components/feed-reel/vertical-reel.vue
  24. 118 0
      components/find/play.vue
  25. 642 0
      components/image-cutter/image-cutter.vue
  26. 473 0
      components/live-broadcast/broadcast-setup-sheet.vue
  27. 175 0
      components/live-room/broadcast-overlay.vue
  28. 55 0
      components/live-room/bulletin.vue
  29. 267 0
      components/live-room/comment.vue
  30. 1751 0
      components/live-room/control.vue
  31. 44 0
      components/live-room/empty.vue
  32. 413 0
      components/live-room/footer.vue
  33. 91 0
      components/live-room/gift-animation-h5.js
  34. 583 0
      components/live-room/gift-animation.vue
  35. 805 0
      components/live-room/gift-panel.vue
  36. 264 0
      components/live-room/header.vue
  37. 64 0
      components/live-room/image.vue
  38. 167 0
      components/live-room/insufficient-balance-modal.vue
  39. 278 0
      components/live-room/message.vue
  40. 58 0
      components/live-room/play.vue
  41. 44 0
      components/live-room/tip.vue
  42. 96 0
      components/live-watch/anchor-bar.vue
  43. 113 0
      components/live-watch/chat-dock.vue
  44. 219 0
      components/live-watch/live-chat-panel.vue
  45. 77 0
      components/live-watch/paywall-mask.vue
  46. 236 0
      components/live-watch/prism-host.vue
  47. 32 0
      components/live-watch/status-plate.vue
  48. 206 0
      components/live-watch/stream-player-external.nvue
  49. 176 0
      components/live-watch/stream-player.nvue
  50. 217 0
      components/live-watch/stream-player.vue
  51. 47 0
      components/live-watch/stream-view.vue
  52. 155 0
      components/mall/mall-bank.vue
  53. 107 0
      components/mall/mall-category-content.vue
  54. 263 0
      components/mall/mall-home-content.vue
  55. 214 0
      components/mall/mall-navbar.vue
  56. 225 0
      components/mall/mall-product.vue
  57. 122 0
      components/mall/mall-sort-bar.vue
  58. 115 0
      components/mall/mall-waterfall.vue
  59. 379 0
      components/mine-drawer-shell/mine-drawer-shell.vue
  60. 553 0
      components/mine-side-panel/mine-side-panel.vue
  61. 186 0
      components/passport-kit/passcode-grid.vue
  62. 221 0
      components/passport-kit/passport-screen.vue
  63. 135 0
      components/passport-kit/policy-pledge.vue
  64. 400 0
      components/passport-kit/region-picker.vue
  65. 432 0
      components/profile/relation-list.vue
  66. 255 0
      components/shop/shop-classify.vue
  67. 116 0
      components/shop/shop-find.vue
  68. 285 0
      components/shop/shop-head.vue
  69. 113 0
      components/shop/shop-page.vue
  70. 188 0
      components/shop/shop-product.vue
  71. 465 0
      components/travel/city-picker/city-picker.vue
  72. 501 0
      components/travel/date-range-picker/date-range-picker.vue
  73. 258 0
      components/travel/hotel-card/hotel-card.vue
  74. 438 0
      components/travel/passenger-picker/passenger-picker.vue
  75. 19 0
      components/uni-load-more/uni-load-more.vue
  76. 319 0
      components/uni-popup/live-video-paypop.vue
  77. 275 0
      components/uni-popup/uni-popup.vue
  78. 279 0
      components/uni-transition/uni-transition.vue
  79. 14 0
      core/api/business.js
  80. 1 0
      core/api/index.js
  81. 28 0
      core/auth/session-guard.js
  82. 72 0
      core/bootstrap/app-launch.js
  83. 16 0
      core/bootstrap/locale-bootstrap.js
  84. 15 0
      core/config/api-paths.js
  85. 7 0
      core/config/app-settings.js
  86. 20 0
      core/constants/storage-keys.js
  87. 0 0
      core/html/html-parser.js
  88. 91 0
      core/http/http-client.js
  89. 26 0
      core/http/http-error.js
  90. 13 0
      core/http/index.js
  91. 149 0
      core/http/interceptor-registry.js
  92. 56 0
      core/http/response-evaluator.js
  93. 35 0
      core/infra/codec-base64.js
  94. 5 0
      core/infra/hash-digest.js
  95. 60 0
      core/infra/runtime-bridge.js
  96. 385 0
      core/infra/vendor/md5-rfc1321.js
  97. 17 0
      core/kit/device-metrics.js
  98. 22 0
      core/kit/random-id.js
  99. 91 0
      core/kit/runtime-toolkit.js
  100. 13 0
      core/math/number-format.js

+ 17 - 0
.gitignore

@@ -0,0 +1,17 @@
+# 忽略 uniapp 构建产物(这就是你刚才关心那几个目录的“总开关”)
+unpackage/
+
+# 忽略依赖包
+node_modules/
+
+# 忽略系统文件
+.DS_Store
+Thumbs.db
+
+# 忽略编辑器个人配置
+.idea/
+.vscode/
+*.suo
+
+# 忽略 HBuilderX 相关文件
+.hbuilderx/

+ 41 - 0
App.vue

@@ -0,0 +1,41 @@
+<script>
+import { runColdStart, runForeground, runBackground } from '@/core/bootstrap/app-launch'
+import appSettings from '@/core/config/app-settings'
+import { createPushBridge } from '@/features/push/push-bridge'
+import { markAppInBackground } from '@/features/feed/background-playback'
+
+export default {
+	globalData: {
+		runtimeConfig: {},
+		targetVideoId: '',
+		_i18n: {},
+		isAndroid: true
+	},
+	onLaunch() {
+		// #ifdef APP-PLUS
+		if (appSettings.nativePushEnabled) {
+			this.$pushBridge = createPushBridge(this)
+		}
+		// #endif
+		runColdStart(this)
+	},
+	onShow() {
+		markAppInBackground(false)
+		runForeground(this)
+	},
+	onHide() {
+		markAppInBackground(true)
+		runBackground(this)
+	}
+}
+</script>
+
+<style lang="scss">
+/* #ifndef APP-NVUE */
+@import '@/static/style/app-shell.css';
+@import '@/static/style/base.css';
+@import '@/static/style/colorui.css';
+@import './static/style/baselive.css';
+@import '@/static/style/iconfont.css';
+/* #endif */
+</style>

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 DCloud
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 15 - 0
README.md

@@ -0,0 +1,15 @@
+# hello-i18n
+
+# en
+
+A demo project for uni-app globalization
+
+This template include uni-framework, manifest.json, pages.json, tabbar, Page, Component, API
+
+
+# zh-hans
+
+uni-app 国际化演示
+
+包含 uni-framework、manifest.json、pages.json、tabbar、页面、组件、API
+

+ 55 - 0
bootstrap/i18n-factory.js

@@ -0,0 +1,55 @@
+import messages from '@/locale/index'
+
+const REQUIRED_STRINGS = [
+	['apiNotFound', 'API 接口不存在'],
+	['apiServerBusy', 'API 服務器繁忙']
+]
+
+function repairLocaleBundles(bundle) {
+	const output = { ...bundle }
+	Object.keys(output).forEach((locale) => {
+		if (!output[locale].common) output[locale].common = {}
+		const common = output[locale].common
+		REQUIRED_STRINGS.forEach(([key, fallback]) => {
+			if (typeof common[key] !== 'string' || !common[key]) {
+				console.warn(`[i18n] repair ${locale}.common.${key}`)
+				common[key] = fallback
+			}
+		})
+	})
+	return output
+}
+
+function resolveInitialLocale() {
+	const saved = uni.getStorageSync('language')
+	let locale = saved || uni.getLocale()
+	if (locale === 'auto') locale = uni.getLocale()
+	return locale
+}
+
+const i18nOptions = {
+	locale: resolveInitialLocale(),
+	fallbackLocale: 'zh-Hans',
+	silentFallbackWarn: true,
+	messages: repairLocaleBundles(messages)
+}
+
+// #ifndef VUE3
+import VueI18n from 'vue-i18n'
+
+export function createAppI18n(Vue) {
+	Vue.use(VueI18n)
+	return new VueI18n(i18nOptions)
+}
+// #endif
+
+// #ifdef VUE3
+import { createI18n } from 'vue-i18n'
+
+export function createAppI18n() {
+	return createI18n({
+		legacy: false,
+		...i18nOptions
+	})
+}
+// #endif

+ 55 - 0
bootstrap/launcher.js

@@ -0,0 +1,55 @@
+import App from '@/App'
+import store from '@/store'
+import appSettings from '@/core/config/app-settings'
+import mpShareMixin from '@/platform/mp-share'
+import Base64Codec from '@/core/infra/codec-base64'
+import { registerHttpInterceptors, createHttpFacade } from '@/core/http'
+import * as businessApi from '@/core/api'
+import { buildRuntimeToolkit } from '@/core/kit/runtime-toolkit'
+import { installGlobalShortcuts } from '@/features/global-shortcuts'
+import { createAppI18n } from './i18n-factory'
+
+// #ifdef H5
+import { runningInsideWeChat } from '@/platform/h5-wechat'
+// #endif
+
+function attachSharedRuntime(host, i18n) {
+	const sink = host.prototype || host
+	sink.$store = store
+	sink.$base64 = new Base64Codec()
+	// #ifdef H5
+	sink.$jssdk = { isWechat: runningInsideWeChat }
+	// #endif
+	sink.$appKit = buildRuntimeToolkit(i18n)
+	sink.$http = createHttpFacade()
+	sink.$api = businessApi
+	installGlobalShortcuts(sink)
+	registerHttpInterceptors(i18n)
+}
+
+// #ifndef VUE3
+import Vue from 'vue'
+
+export function bootstrapVue2App(RootApp = App) {
+	const i18n = createAppI18n(Vue)
+	Vue.mixin(mpShareMixin)
+	Vue.config.productionTip = !!appSettings.debug
+	attachSharedRuntime(Vue, i18n)
+	RootApp.mpType = 'app'
+	return new Vue({ i18n, store, ...RootApp }).$mount()
+}
+// #endif
+
+// #ifdef VUE3
+import { createSSRApp } from 'vue'
+
+export function bootstrapVue3App(RootApp = App) {
+	const i18n = createAppI18n()
+	const app = createSSRApp(RootApp)
+	app.use(store)
+	app.use(i18n)
+	app.mixin(mpShareMixin)
+	attachSharedRuntime(app.config.globalProperties, i18n)
+	return { app }
+}
+// #endif

+ 10 - 0
changelog.md

@@ -0,0 +1,10 @@
+## 1.0.3(2025-06-26)
+- index.html 模板中的 lang="en" 改成 lang="zh-CN"
+## 1.0.2(2022-06-30)
+- 新增 支持 ios 安全区
+## 1.0.1(2021-11-26)
+- 新增 schema 国际化 (HBuilderX 3.3+)
+- 修复 非 Android 平台切换语音无法实时变化的问题
+- 修复 设置某些语言下无法生效的问题 (HBuilderX 3.3+)
+## 1.0.0(2021-10-20)
+- 初始化

+ 1 - 0
cli-help.txt

@@ -0,0 +1 @@
+娌℃湁鍙敤鐨勫懡浠わ紝灏濊瘯浣跨敤 cli.exe open

+ 21 - 0
common/chat-utils.js

@@ -0,0 +1,21 @@
+/**
+ * 保存聊天記錄到本地緩存
+ * @param {Object} data - 消息數據
+ * @param {String} send - 'send' 表示發送的消息,其他表示接收的消息
+ */
+export function setChat(data, send) {
+  let uid = send === 'send' ? data.to_id : data.form.id
+  uni.getStorage({
+    key: 'wanlchat:message_' + uid,
+    success: function (res) {
+      // 只保存最近 100 條消息,超過的通過接口獲取歷史記錄
+      let arr = res.data.slice(-96)
+      arr.push(data)
+      uni.setStorageSync('wanlchat:message_' + uid, arr)
+    },
+    fail: function (res) {
+      uni.setStorageSync('wanlchat:message_' + uid, [data])
+    }
+  })
+  return data
+}

+ 121 - 0
common/config/runtime-manifest.js

@@ -0,0 +1,121 @@
+/**
+ * 运行时环境清单(与历史扁平 config 等价字段,实现方式完全不同)
+ * 修改环境时优先改下方 ENDPOINTS / IDENTITY 等片段。
+ */
+
+const ENDPOINTS = Object.freeze(
+	Object.assign(Object.create(null), {
+		streamingSocket: 'wss://chat.amazewayhk.com',
+		publicCdnRoot: 'https://backend.amazewayhk.com',
+		apiGatewayRoot: 'https://backend.amazewayhk.com/api'
+	})
+)
+
+const IDENTITY = Object.freeze({
+	releaseLabel: '1.1.30',
+	numericBuild: '1130',
+	// 排查"看不到直播"期间临时开启,正式发布请改回 false
+	traceVerbose: false,
+	geoServiceKey: '',
+	mpOfficialAppId: ''
+})
+
+/** 界面展示开关(改 true 即可放开对应 UI) */
+const UI_FLAGS = Object.freeze({
+	// 底部栏点赞按钮下方的数量(直播观看页 + 视频 feed 底栏)
+	showBottomBarLikeCount: false,
+	// 原生 FCM/VoIP 推送(uni_modules/gzfuts-fcm)
+	// false:不加载 JS 推送桥接;插件需在 _disabled_native,并重打自定义基座后才不会在 iOS 启动 VoIP
+	nativePushEnabled: false,
+})
+
+const HTTP_CLIENT_ALIASES = ['app', 'wechat', 'h5', 'mp'].reduce((acc, slot) => {
+	const tag = `${slot}-hl88live`
+	acc[slot] = slot === 'app' ? `app-hl88live` : tag
+	return acc
+}, Object.create(null))
+
+function assembleIceCatalog() {
+	const host = 'rtc.abtim-my.com:3478'
+	const sharedAuth = { username: 'admin', credential: 'k8sF3mNpQ2xR7vT9wL5z' }
+	return [
+		{
+			urls: [`stun:${host}`],
+			...sharedAuth
+		},
+		{
+			urls: [`turn:${host}?transport=udp`, `turn:${host}?transport=tcp`],
+			...sharedAuth
+		}
+	]
+}
+
+function backendAddonFolder() {
+	return ['w', 'a', 'n', 'l', 's', 'h', 'o', 'p'].join('')
+}
+
+/**
+ * @returns {Readonly<Record<string, unknown>>} 与拦截器、store 约定的扁平字段及派生 getter
+ */
+export function materializeRuntimeManifest() {
+	const ice = assembleIceCatalog()
+	const addon = backendAddonFolder()
+
+	return Object.freeze({
+		get socketurl() {
+			return ENDPOINTS.streamingSocket
+		},
+		get cdnurl() {
+			return ENDPOINTS.publicCdnRoot
+		},
+		get appurl() {
+			return ENDPOINTS.apiGatewayRoot
+		},
+		get amapkey() {
+			return IDENTITY.geoServiceKey
+		},
+		get appid() {
+			return IDENTITY.mpOfficialAppId
+		},
+		get versionName() {
+			return IDENTITY.releaseLabel
+		},
+		get versionCode() {
+			return IDENTITY.numericBuild
+		},
+		get debug() {
+			return IDENTITY.traceVerbose
+		},
+		get showBottomBarLikeCount() {
+			return UI_FLAGS.showBottomBarLikeCount
+		},
+		get nativePushEnabled() {
+			return UI_FLAGS.nativePushEnabled
+		},
+		get restPathPrefix() {
+			return `/${addon}`
+		},
+		get cdnStaticAddon() {
+			return `/assets/addons/${addon}/img`
+		},
+		get httpAppClient() {
+			return Object.freeze({ ...HTTP_CLIENT_ALIASES })
+		},
+		get webrtcIceServers() {
+			return ice
+		},
+		get avcallTestSignalingWs() {
+			return ''
+		}
+	})
+}
+
+/** 进程内单例,避免重复组装 */
+let cachedSurface = null
+
+export function getManifestSingleton() {
+	if (!cachedSurface) cachedSurface = materializeRuntimeManifest()
+	return cachedSurface
+}
+
+export default getManifestSingleton

+ 52 - 0
common/mixins/resend-cooldown.js

@@ -0,0 +1,52 @@
+/**
+ * 通用「重发冷却」mixin。
+ *
+ * 把"剩余秒数 / 启停 / 自动清理"内聚成单一关注点,避免每个页面重复写 setInterval。
+ *
+ * 使用:
+ *   import resendCooldown from '@/common/mixins/resend-cooldown'
+ *   mixins: [resendCooldown]
+ *   // this.coolLeft   剩余秒数 (0 表示空闲)
+ *   // this.coolReady  是否可重新点击
+ *   // this.coolStart(60)  启动
+ *   // this.coolStop()      强行停止
+ */
+
+const DEFAULT_SPAN = 60
+
+export default {
+	data() {
+		return {
+			coolLeft: 0,
+			coolHandle: null
+		}
+	},
+	computed: {
+		coolReady() {
+			return this.coolLeft <= 0
+		}
+	},
+	beforeDestroy() {
+		this.coolStop()
+	},
+	methods: {
+		coolStart(span = DEFAULT_SPAN) {
+			this.coolStop()
+			this.coolLeft = Math.max(1, Math.floor(span))
+			this.coolHandle = setInterval(() => {
+				if (this.coolLeft <= 1) {
+					this.coolStop()
+					return
+				}
+				this.coolLeft = this.coolLeft - 1
+			}, 1000)
+		},
+		coolStop() {
+			if (this.coolHandle) {
+				clearInterval(this.coolHandle)
+				this.coolHandle = null
+			}
+			this.coolLeft = 0
+		}
+	}
+}

+ 260 - 0
components/address-picker/address-picker.vue

@@ -0,0 +1,260 @@
+<template>
+	<view class="wrapper" v-show="isShowMask">
+		<view class="content_view" v-show="isShow">
+			<view class="title_view">
+				<view class="title">請選擇所在地區</view>
+				<view class="close_view" @tap="hidden">
+					<text class="wlIcon wlIcon-31guanbi close_icon"></text>
+				</view>
+			</view>
+			<view class="select_top">
+				<view class="select_top_item" v-for="(item, index) in dataList" :key="index" @tap="select_top_item_click(index)">
+					<text class="address_value">{{ item }}</text>
+				</view>
+				<view class="indicator" :style="{ left: indicatorStyleLeft + 'px' }"></view>
+			</view>
+			<swiper class="swiper" :current="currentIndex" @change="swiperChange">
+				<swiper-item v-for="(swiper_item, swiper_index) in dataList" :key="swiper_index">
+					<view class="swiper-item">
+						<scroll-view class="scroll-view-item" scroll-y="true">
+							<view class="address_item" v-for="(item, index) in cityAreaArray[swiper_index]" :key="index" @tap="address_item_click(swiper_index, index)">
+								<text v-if="selectIndexArr[swiper_index] === index" class="address_item_icon">✓</text>
+								{{ item.name }}
+							</view>
+						</scroll-view>
+					</view>
+				</swiper-item>
+			</swiper>
+		</view>
+		<view class="mask" @tap="hidden" v-show="isShowMask"></view>
+	</view>
+</template>
+
+<script>
+import { get } from '@/core/http/http-client'
+import { buildRestPath } from '@/core/config/api-paths'
+import appSettings from '@/core/config/app-settings'
+
+export default {
+	name: 'AddressPicker',
+	data() {
+		return {
+			isShow: false,
+			isShowMask: false,
+			dataList: ['請選擇'],
+			currentIndex: 0,
+			cityData: {},
+			cityAreaArray: [],
+			selectIndexArr: [],
+			indicatorStyleLeft: 16
+		}
+	},
+	created() {
+		this.loadAreaData()
+	},
+	methods: {
+		async loadAreaData() {
+			try {
+				const { data } = await get(buildRestPath('common/area'))
+				if (data && data.length > 0) {
+					this.cityData = data
+					this.cityAreaArray = [data]
+				} else {
+					uni.showToast({ title: '地區數據為空', icon: 'none' })
+				}
+			} catch (e) {
+				uni.showToast({ title: '加載地區數據失敗', icon: 'none' })
+			}
+		},
+		show() {
+			this.isShow = true
+			this.isShowMask = true
+		},
+		hidden() {
+			this.isShow = false
+			this.isShowMask = false
+		},
+		select_top_item_click(index) {
+			this.currentIndex = index
+		},
+		swiperChange(event) {
+			this.currentIndex = event.detail.current
+		},
+		address_item_click(swiper_index, index) {
+			this.selectIndexArr.splice(swiper_index, 5, index)
+			if (swiper_index === 0) {
+				let currentObj = this.cityData[index]
+				let city = currentObj.name
+				this.dataList.splice(swiper_index, 5, city)
+				this.dataList.splice(swiper_index + 1, 0, '請選擇')
+				this.cityAreaArray.splice(swiper_index + 1, 1, currentObj['city'])
+				setTimeout(() => {
+					this.currentIndex = 1
+				}, 50)
+			} else {
+				let currentAreaArray = this.cityAreaArray[swiper_index]
+				let currentObj = currentAreaArray[index]
+				let area = currentObj['area']
+				if (area !== undefined) {
+					let city = currentObj.name
+					this.dataList.splice(swiper_index, 5, city)
+					this.dataList.splice(swiper_index + 1, 0, '請選擇')
+					this.cityAreaArray.splice(swiper_index + 1, 1, currentObj['area'])
+					setTimeout(() => {
+						this.currentIndex = swiper_index + 1
+					}, 50)
+				} else {
+					let city = currentObj.name
+					this.dataList.splice(swiper_index, 1, city)
+					let geocodes = {
+						country: '',
+						province: this.dataList[0] || '',
+						city: this.dataList[1] || '',
+						district: currentObj.name,
+						citycode: currentObj.code || '',
+						adcode: currentObj.zip || '',
+						formatted_address: this.dataList.join(''),
+						location: ''
+					}
+					let amapkey = appSettings.amapkey
+					if (amapkey) {
+						uni.request({
+							url: 'https://restapi.amap.com/v3/geocode/geo',
+							data: {
+								address: this.dataList.join(''),
+								key: amapkey
+							},
+							success: res => {
+								if (res.data && res.data.geocodes && res.data.geocodes.length > 0) {
+									let geo = res.data.geocodes[0]
+									geocodes.country = geo.country || ''
+									geocodes.province = geo.province || geocodes.province
+									geocodes.city = geo.city || geocodes.city
+									geocodes.district = geo.district || geocodes.district
+									geocodes.citycode = geo.citycode || geocodes.citycode
+									geocodes.adcode = geo.adcode || geocodes.adcode
+									geocodes.formatted_address = geo.formatted_address || geocodes.formatted_address
+									geocodes.location = geo.location || ''
+								}
+								this.$emit('selectAddress', geocodes)
+								this.isShow = false
+								this.isShowMask = false
+							},
+							fail: () => {
+								this.$emit('selectAddress', geocodes)
+								this.isShow = false
+								this.isShowMask = false
+							}
+						})
+					} else {
+						this.$emit('selectAddress', geocodes)
+						this.isShow = false
+						this.isShowMask = false
+					}
+				}
+			}
+		}
+	}
+}
+</script>
+
+<style scoped>
+.wrapper {
+	z-index: 9990;
+	position: fixed;
+	top: 0;
+	left: 0;
+	bottom: 0;
+	right: 0;
+}
+.content_view {
+	z-index: 9991;
+	background: white;
+	position: absolute;
+	height: 80%;
+	left: 0;
+	bottom: 0;
+	right: 0;
+	border-top-left-radius: 20rpx;
+	border-top-right-radius: 20rpx;
+}
+.title_view {
+	height: 8%;
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 0 20rpx;
+}
+.title_view .title {
+	font-size: 28rpx;
+	font-weight: 600;
+}
+.close_view {
+	height: 60rpx;
+	width: 60rpx;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+}
+.close_icon {
+	font-size: 40rpx;
+	color: #999;
+}
+.select_top {
+	height: 8%;
+	display: flex;
+	justify-content: flex-start;
+	align-items: center;
+	padding: 0 20rpx 20rpx 20rpx;
+	position: relative;
+	box-sizing: border-box;
+}
+.select_top_item {
+	width: 160rpx;
+	font-size: 28rpx;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.indicator {
+	position: absolute;
+	width: 60rpx;
+	height: 4rpx;
+	background: #ff6b35;
+	left: 32rpx;
+	bottom: 0;
+	transition: left 0.3s ease;
+}
+.swiper {
+	height: 80%;
+}
+.swiper-item {
+	height: 100%;
+}
+.scroll-view-item {
+	height: 100%;
+	padding: 0 20rpx;
+}
+.address_item {
+	padding: 20rpx 0;
+	font-size: 28rpx;
+	display: flex;
+	align-items: center;
+	border-bottom: 1rpx solid #f5f5f5;
+}
+.address_item_icon {
+	color: #ff6b35;
+	margin-right: 16rpx;
+	font-size: 32rpx;
+	font-weight: bold;
+}
+.mask {
+	position: absolute;
+	top: 0;
+	left: 0;
+	bottom: 0;
+	right: 0;
+	background: rgba(0, 0, 0, 0.4);
+	z-index: 9990;
+}
+</style>

+ 228 - 0
components/app-tabbar/app-tabbar.vue

@@ -0,0 +1,228 @@
+<template>
+	<view class="app-tabbar" :style="{ paddingBottom: safeBottom + 'px' }">
+		<view class="app-tabbar-inner">
+			<view
+				v-for="tab in tabs"
+				:key="tab.id"
+				class="tabbar-item"
+				@tap="onTap(tab)"
+			>
+				<view v-if="active === tab.id" class="tabbar-pill">
+					<image class="tabbar-pill-icon" :src="tab.iconActive" mode="aspectFit" />
+					<text class="tabbar-pill-label">{{ tab.label }}</text>
+				</view>
+				<template v-else>
+					<image class="tabbar-icon" :src="tab.icon" mode="aspectFit" />
+				</template>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import { STORAGE } from '@/core/constants/storage-keys'
+
+/**
+ * 全局自定义 tabbar。
+ *
+ * 用法:在每个 tab 页底部放一个 `<app-tabbar :active="'home'" />`,
+ * 并在 onShow 里调一次 `uni.hideTabBar({ animation: false })` 把系统 tabbar 藏起来。
+ *
+ * pages.json 仍要保留 tabBar.list 配置——只为了让 uni.switchTab 路由能工作,
+ * 视觉上由本组件全权负责。
+ *
+ * 文案不走 $t:nvue 内嵌子 webview 常拿不到 vue-i18n,故按当前语言直接取映射表。
+ */
+const TAB_DEFS = [
+	{
+		id: 'home',
+		labelId: 'home',
+		path: '/pages/index/index',
+		icon: '/static/tabbar/home.png',
+		iconActive: '/static/tabbar/home-active.png'
+	},
+	{
+		id: 'favorite',
+		labelId: 'video',
+		path: '/pages/feed/index',
+		icon: '/static/tabbar/favorite.png',
+		iconActive: '/static/tabbar/favorite-active.png'
+	},
+	{
+		id: 'live',
+		labelId: 'live',
+		path: '/pages/live/hall',
+		icon: '/static/tabbar/live.png',
+		iconActive: '/static/tabbar/live-active.png'
+	},
+	{
+		id: 'dynamics',
+		labelId: 'dynamics',
+		path: '/pages/dynamics/index',
+		icon: '/static/tabbar/bell.png',
+		iconActive: '/static/tabbar/bell-active.png'
+	},
+	{
+		id: 'mine',
+		labelId: 'mine',
+		path: '/pages/mine/index',
+		icon: '/static/tabbar/mine.png',
+		iconActive: '/static/tabbar/mine-active.png'
+	}
+]
+
+const LANGUAGE_MAP = {
+	'zh-Hans': {
+		home: '首页',
+		video: '视频',
+		live: '直播',
+		dynamics: '动态',
+		mine: '我的'
+	},
+	'zh-Hant': {
+		home: '首頁',
+		video: '視頻',
+		live: '直播',
+		dynamics: '動態',
+		mine: '我的'
+	},
+	en: {
+		home: 'Home',
+		video: 'Videos',
+		live: 'Live',
+		dynamics: 'Feed',
+		mine: 'Me'
+	}
+}
+
+function resolveLangCode(raw) {
+	let code = raw
+	if (!code || code === 'auto') {
+		code = uni.getLocale() || 'zh-Hans'
+	}
+	if (LANGUAGE_MAP[code]) return code
+	if (code === 'zh-HK' || code === 'zh-TW' || String(code).indexOf('Hant') >= 0) {
+		return 'zh-Hant'
+	}
+	if (String(code).indexOf('zh') === 0) return 'zh-Hans'
+	if (String(code).indexOf('en') === 0) return 'en'
+	return 'zh-Hans'
+}
+
+export default {
+	name: 'AppTabbar',
+	props: {
+		active: { type: String, default: 'home' }
+	},
+	data() {
+		return {
+			safeBottom: 0,
+			switching: false,
+			labels: LANGUAGE_MAP['zh-Hant']
+		}
+	},
+	computed: {
+		tabs() {
+			const L = this.labels || LANGUAGE_MAP['zh-Hans']
+			return TAB_DEFS.map((tab) => ({
+				...tab,
+				label: L[tab.labelId] || ''
+			}))
+		}
+	},
+	mounted() {
+		this.initLanguageStrings()
+		try {
+			const info = uni.getSystemInfoSync()
+			this.safeBottom = (info.safeAreaInsets && info.safeAreaInsets.bottom) || 0
+		} catch (_) {
+			this.safeBottom = 0
+		}
+		if (typeof uni.onLocaleChange === 'function') {
+			uni.onLocaleChange(() => {
+				this.initLanguageStrings()
+			})
+		}
+	},
+	methods: {
+		initLanguageStrings() {
+			const locale = uni.getLocale() || 'zh-Hans'
+			const savedLanguage = uni.getStorageSync(STORAGE.language)
+			const currentLang = resolveLangCode(savedLanguage || locale)
+			this.labels = LANGUAGE_MAP[currentLang] || LANGUAGE_MAP['zh-Hans']
+		},
+		onTap(tab) {
+			if (this.switching) return
+			if (this.active === tab.id) return
+			this.switching = true
+			this.$nextTick(() => {
+				try {
+					uni.switchTab({
+						url: tab.path,
+						complete: () => {
+							this.switching = false
+						},
+						fail: (err) => {
+							console.warn('[AppTabbar] switchTab failed', tab.path, err)
+						}
+					})
+				} catch (err) {
+					this.switching = false
+					console.warn('[AppTabbar] switchTab threw', err)
+				}
+			})
+		}
+	}
+}
+</script>
+
+<style scoped>
+.app-tabbar {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: #ffffff;
+	border-top: 1rpx solid rgba(0, 0, 0, 0.06);
+	box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
+	z-index: 9999;
+}
+.app-tabbar-inner {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	height: 100rpx;
+}
+.tabbar-item {
+	flex: 1;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+}
+.tabbar-icon {
+	width: 48rpx;
+	height: 48rpx;
+}
+.tabbar-pill {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: center;
+	padding: 10rpx 28rpx;
+	border-radius: 999rpx;
+	background: linear-gradient(135deg, rgba(74, 140, 255, 0.14) 0%, rgba(74, 200, 255, 0.18) 100%);
+	box-shadow: 0 4rpx 16rpx rgba(74, 140, 255, 0.16);
+}
+.tabbar-pill-icon {
+	width: 40rpx;
+	height: 40rpx;
+	margin-right: 8rpx;
+}
+.tabbar-pill-label {
+	font-size: 24rpx;
+	color: #4a8cff;
+	font-weight: 600;
+	line-height: 32rpx;
+}
+</style>

+ 262 - 0
components/app-update-sheet/app-update-sheet.vue

@@ -0,0 +1,262 @@
+<template>
+	<view v-if="visible" class="upd-mask" @tap.stop="onMaskTap">
+		<view class="upd-sheet" @tap.stop>
+			<view class="upd-head">
+				<view class="upd-head-icon-wrap">
+					<text class="upd-head-icon">↑</text>
+				</view>
+				<view class="upd-head-text">
+					<text class="upd-title">{{ meta.title || copy.title }}</text>
+					<text class="upd-version">{{ copy.latest }} {{ meta.versionName || '' }}</text>
+				</view>
+			</view>
+
+			<scroll-view scroll-y class="upd-body">
+				<rich-text class="upd-content" :nodes="meta.content || ''" />
+			</scroll-view>
+
+			<view v-if="download.start" class="upd-progress-wrap">
+				<view class="upd-progress-bar">
+					<view class="upd-progress-fill" :style="{ width: download.progress + '%' }" />
+				</view>
+				<text class="upd-progress-txt">{{ download.progress }}%</text>
+				<text class="upd-progress-sub">
+					{{ copy.downloading }}
+				</text>
+			</view>
+
+			<view v-else-if="download.install" class="upd-install">
+				<text class="upd-install-txt">{{ copy.installing }}</text>
+			</view>
+
+			<view v-else class="upd-actions">
+				<button
+					v-if="!enforce"
+					class="upd-btn upd-btn--ghost"
+					@tap="onIgnore"
+				>
+					<text class="upd-btn-txt">{{ copy.ignore }}</text>
+				</button>
+				<button class="upd-btn upd-btn--primary" @tap="onDownload">
+					<text class="upd-btn-txt upd-btn-txt--light">{{ copy.updateNow }}</text>
+				</button>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+
+export default {
+	name: 'AppUpdateSheet',
+	computed: {
+		...mapState('release', {
+			visible: (state) => state.visible,
+			meta: (state) => state.meta,
+			download: (state) => state.download,
+			link: (state) => state.link
+		}),
+		enforce() {
+			return Number(this.meta && this.meta.enforce) === 1
+		},
+		copy() {
+			const t = this.$t.bind(this)
+			return {
+				title: t('update.title'),
+				latest: t('update.latest'),
+				ignore: t('update.ignore'),
+				updateNow: t('update.updateNow'),
+				installing: t('update.installing'),
+				downloading: t('update.downloading')
+			}
+		}
+	},
+	methods: {
+		onMaskTap() {
+			if (this.enforce) return
+			this.onIgnore()
+		},
+		onIgnore() {
+			this.$store.dispatch('release/ignore')
+		},
+		onDownload() {
+			if (!this.link || !this.link.url) {
+				this.$appKit.msg(this.copy.updateNow)
+				return
+			}
+			this.$store.dispatch('release/startDownload')
+		}
+	}
+}
+</script>
+
+<style scoped>
+.upd-mask {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	z-index: 9999;
+	background: rgba(15, 23, 42, 0.45);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	padding: 48rpx 40rpx;
+	box-sizing: border-box;
+}
+
+.upd-sheet {
+	width: 100%;
+	max-width: 640rpx;
+	background: #ffffff;
+	border-radius: 28rpx;
+	overflow: hidden;
+	box-shadow: 0 24rpx 64rpx rgba(74, 140, 255, 0.18);
+}
+
+.upd-head {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	padding: 32rpx 32rpx 20rpx;
+	background: linear-gradient(135deg, #4a8cff 0%, #6b5cff 100%);
+}
+
+.upd-head-icon-wrap {
+	width: 88rpx;
+	height: 88rpx;
+	border-radius: 24rpx;
+	background: rgba(255, 255, 255, 0.2);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	margin-right: 20rpx;
+	flex-shrink: 0;
+}
+
+.upd-head-icon {
+	font-size: 44rpx;
+	color: #ffffff;
+	font-weight: 700;
+	line-height: 1;
+}
+
+.upd-head-text {
+	flex: 1;
+	min-width: 0;
+}
+
+.upd-title {
+	display: block;
+	font-size: 32rpx;
+	font-weight: 700;
+	color: #ffffff;
+	margin-bottom: 8rpx;
+}
+
+.upd-version {
+	display: block;
+	font-size: 24rpx;
+	color: rgba(255, 255, 255, 0.88);
+}
+
+.upd-body {
+	max-height: 360rpx;
+	padding: 24rpx 32rpx 8rpx;
+	box-sizing: border-box;
+}
+
+.upd-content {
+	font-size: 28rpx;
+	line-height: 1.55;
+	color: #4b5563;
+}
+
+.upd-progress-wrap {
+	padding: 8rpx 32rpx 32rpx;
+}
+
+.upd-progress-bar {
+	height: 12rpx;
+	border-radius: 6rpx;
+	background: #eef1f6;
+	overflow: hidden;
+	margin-bottom: 12rpx;
+}
+
+.upd-progress-fill {
+	height: 100%;
+	background: linear-gradient(90deg, #4a8cff 0%, #6b5cff 100%);
+	border-radius: 6rpx;
+}
+
+.upd-progress-txt {
+	display: block;
+	font-size: 26rpx;
+	color: #1a1d26;
+	font-weight: 600;
+	text-align: center;
+}
+
+.upd-progress-sub {
+	display: block;
+	margin-top: 8rpx;
+	font-size: 24rpx;
+	color: #8a8d9e;
+	text-align: center;
+}
+
+.upd-install {
+	padding: 24rpx 32rpx 36rpx;
+	text-align: center;
+}
+
+.upd-install-txt {
+	font-size: 28rpx;
+	color: #4a8cff;
+}
+
+.upd-actions {
+	display: flex;
+	flex-direction: row;
+	padding: 16rpx 32rpx 32rpx;
+	box-sizing: border-box;
+}
+
+.upd-btn {
+	flex: 1;
+	margin-right: 20rpx;
+	height: 88rpx;
+	line-height: 88rpx;
+	border-radius: 44rpx;
+	margin: 0;
+	padding: 0;
+	border: none;
+}
+
+.upd-btn::after {
+	border: none;
+}
+
+.upd-btn--ghost {
+	background: #f0f3f8;
+}
+
+.upd-btn--primary {
+	background: linear-gradient(135deg, #4a8cff 0%, #6b5cff 100%);
+	box-shadow: 0 10rpx 28rpx rgba(74, 140, 255, 0.28);
+	margin-right: 0;
+}
+
+.upd-btn-txt {
+	font-size: 30rpx;
+	font-weight: 600;
+	color: #4b5563;
+}
+
+.upd-btn-txt--light {
+	color: #ffffff;
+}
+</style>

+ 491 - 0
components/dynamics/detail-comment-panel.vue

@@ -0,0 +1,491 @@
+<template>
+	<view class="dcp-root">
+		<scroll-view
+			v-if="rows.length > 0"
+			class="dcp-list"
+			scroll-y
+			:show-scrollbar="false"
+		>
+			<view
+				v-for="(item, index) in rows"
+				:key="(item && item.id) || index"
+				class="dcp-row"
+			>
+				<image
+					class="dcp-avatar"
+					:src="avatarSrc(item && item.user && item.user.avatar)"
+					@tap.stop="onUser(item)"
+				/>
+				<view class="dcp-body">
+					<view class="dcp-head">
+						<view class="dcp-head-left">
+							<view v-if="index === 0" class="dcp-rank dcp-rank-1">
+								<text class="dcp-rank-text">第一</text>
+							</view>
+							<view v-else-if="index === 1" class="dcp-rank dcp-rank-2">
+								<text class="dcp-rank-text">第二</text>
+							</view>
+							<view v-else-if="index === 2" class="dcp-rank dcp-rank-3">
+								<text class="dcp-rank-text">第三</text>
+							</view>
+							<text class="dcp-nick">{{ (item.user && item.user.nickname) || '游客' }}</text>
+						</view>
+						<view class="dcp-like" @tap.stop="onLike(index)">
+							<text :class="item.hasLike ? 'dcp-like-num dcp-like-num-on' : 'dcp-like-num'">{{ item.like || 0 }}</text>
+							<text :class="item.hasLike ? 'dcp-like-icon dcp-like-icon-on' : 'dcp-like-icon'">{{ item.hasLike ? '♥' : '♡' }}</text>
+						</view>
+					</view>
+					<text class="dcp-content">{{ plain(item.content) }}</text>
+					<view class="dcp-meta">
+						<text class="dcp-time">{{ item.createtime_text || '' }}</text>
+						<view
+							class="dcp-action"
+							@tap.stop="onReply(item.user && item.user.nickname, item.id)"
+						>
+							<text class="dcp-action-reply">回复</text>
+						</view>
+						<view v-if="item.owner" class="dcp-action" @tap.stop="onDelete(index)">
+							<text class="dcp-action-del">删除</text>
+						</view>
+					</view>
+					<view v-if="item.childlist && item.childlist.length > 0" class="dcp-sublist">
+						<view
+							v-for="(child, k) in item.childlist"
+							:key="(child && child.id) || k"
+							class="dcp-row dcp-row-sub"
+						>
+							<image
+								class="dcp-avatar dcp-avatar-sub"
+								:src="avatarSrc(child && child.user && child.user.avatar)"
+								@tap.stop="onUser(child)"
+							/>
+							<view class="dcp-body">
+								<view class="dcp-head">
+									<view class="dcp-head-left">
+										<text class="dcp-nick dcp-nick-sub">{{ (child.user && child.user.nickname) || '游客' }}</text>
+									</view>
+									<view class="dcp-like" @tap.stop="onLike(index, k)">
+										<text :class="child.hasLike ? 'dcp-like-num dcp-like-num-on' : 'dcp-like-num'">{{ child.like || 0 }}</text>
+										<text :class="child.hasLike ? 'dcp-like-icon dcp-like-icon-on' : 'dcp-like-icon'">{{ child.hasLike ? '♥' : '♡' }}</text>
+									</view>
+								</view>
+								<text class="dcp-content dcp-content-sub">{{ plain(child.content) }}</text>
+								<view class="dcp-meta">
+									<text class="dcp-time">{{ child.createtime_text || '' }}</text>
+									<view
+										class="dcp-action"
+										@tap.stop="onReply(child.user && child.user.nickname, item.id)"
+									>
+										<text class="dcp-action-reply">回复</text>
+									</view>
+									<view v-if="child.owner" class="dcp-action" @tap.stop="onDelete(index, k)">
+										<text class="dcp-action-del">删除</text>
+									</view>
+								</view>
+							</view>
+						</view>
+					</view>
+				</view>
+			</view>
+		</scroll-view>
+
+		<view v-else class="dcp-empty">
+			<text class="dcp-empty-title">暂无评论,</text>
+			<text class="dcp-empty-cta" @tap.stop="focusInput">来写第一条 →</text>
+		</view>
+
+		<view class="dcp-inputbar" :style="inputBarStyle" @tap.stop>
+			<input
+				ref="commentInput"
+				class="dcp-input"
+				v-model="form.content"
+				:placeholder="placeholder"
+				placeholder-style="color: #b0b4c0;"
+				:maxlength="200"
+				confirm-type="send"
+				@confirm="onSubmit"
+			/>
+			<view class="dcp-send" @tap.stop="onSubmit">
+				<text class="dcp-send-text">发送</text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import { stripHtml } from '@/features/feed/clip-normalize'
+import { buildOssUrl } from '@/core/media/oss-url'
+import {
+	addClipComment,
+	deleteClipComment,
+	fetchClipComments,
+	toggleClipCommentLike
+} from '@/features/feed/feed-gateway'
+import { openUserProfile } from '@/core/nav/navigation'
+
+export default {
+	name: 'DetailCommentPanel',
+	props: {
+		findId: { type: [Number, String], default: 0 },
+		list: { type: Array, default: () => [] },
+		count: { type: Number, default: 0 },
+		safeBottomInset: { type: Number, default: 0 }
+	},
+	data() {
+		return {
+			rows: [],
+			form: { pid: null, content: '' },
+			safeBottom: 0
+		}
+	},
+	computed: {
+		signedIn() {
+			return !!(this.$store && this.$store.state && this.$store.state.user && this.$store.state.user.isLogin)
+		},
+		placeholder() {
+			return this.form.pid ? '回复评论…' : '说点什么~'
+		},
+		inputBarStyle() {
+			const safeB = this.safeBottom || 0
+			return safeB > 0 ? { paddingBottom: `${safeB}px` } : {}
+		}
+	},
+	watch: {
+		list: {
+			immediate: true,
+			handler(next) {
+				this.rows = Array.isArray(next) ? next.slice() : []
+			}
+		}
+	},
+	mounted() {
+		const sys = uni.getSystemInfoSync() || {}
+		const sysSafe = (sys.safeAreaInsets && sys.safeAreaInsets.bottom) || 0
+		this.safeBottom = (this.safeBottomInset && this.safeBottomInset > 0) ? this.safeBottomInset : sysSafe
+	},
+	methods: {
+		plain(content) {
+			return stripHtml(content || '')
+		},
+		avatarSrc(path) {
+			return buildOssUrl(path, 80, 80, 2, 'avatar')
+		},
+		requireSignIn() {
+			if (this.signedIn) return true
+			uni.showModal({
+				title: '请先登录',
+				content: '登录后才能评论、点赞',
+				confirmText: '去登录',
+				cancelText: '取消',
+				success: ({ confirm }) => {
+					if (confirm) uni.navigateTo({ url: '/pages/passport/lobby' })
+				}
+			})
+			return false
+		},
+		onUser(row) {
+			openUserProfile(row)
+		},
+		async onLike(index, childKey = null) {
+			if (!this.requireSignIn()) return
+			const parent = this.rows[index]
+			if (!parent) return
+			const target = childKey == null ? parent : (parent.childlist && parent.childlist[childKey])
+			if (!target || !target.id) return
+			if (target._busy) return
+			target._busy = true
+
+			const was = !!target.hasLike
+			const next = !was
+			this.$set(target, 'hasLike', next)
+			this.$set(target, 'like', Math.max(0, Number(target.like || 0) + (next ? 1 : -1)))
+
+			try {
+				const flag = await toggleClipCommentLike(target.id)
+				if (flag === 1 || flag === 0) {
+					const serverOn = flag === 1
+					if (serverOn !== next) {
+						this.$set(target, 'hasLike', serverOn)
+						this.$set(target, 'like', Math.max(0, Number(target.like || 0) + (serverOn ? 0 : -2)))
+					}
+				}
+			} catch (err) {
+				this.$set(target, 'hasLike', was)
+				this.$set(target, 'like', Math.max(0, Number(target.like || 0) + (was ? 1 : -1)))
+				uni.showToast({ title: (err && err.message) || '点赞失败', icon: 'none' })
+			} finally {
+				target._busy = false
+			}
+		},
+		onReply(reUserNick, pid) {
+			if (!this.requireSignIn()) return
+			this.form.pid = pid
+			this.form.content = reUserNick ? `@${reUserNick} ` : ''
+			this.focusInput()
+		},
+		focusInput() {
+			this.$nextTick(() => {
+				try {
+					this.$refs.commentInput && this.$refs.commentInput.focus && this.$refs.commentInput.focus()
+				} catch (_) {}
+			})
+		},
+		clearForm() {
+			this.form.pid = null
+			this.form.content = ''
+		},
+		async reloadComments() {
+			if (!this.findId) return this.count || 0
+			const result = await fetchClipComments(this.findId)
+			this.rows = Array.isArray(result.list) ? result.list.slice() : []
+			return Number(result.count) || this.rows.length || 0
+		},
+		async onSubmit() {
+			if (!this.requireSignIn()) return
+			const content = (this.form.content || '').trim()
+			if (content.length < 2) {
+				uni.showToast({ title: '内容太短', icon: 'none' })
+				return
+			}
+			try {
+				await addClipComment(this.findId, content, this.form.pid)
+				uni.showToast({ title: '已发送', icon: 'none' })
+				this.clearForm()
+				const nextCount = await this.reloadComments()
+				this.$emit('complete', { type: 'add', count: nextCount, list: this.rows.slice() })
+			} catch (err) {
+				uni.showToast({ title: (err && err.message) || '发送失败', icon: 'none' })
+			}
+		},
+		async onDelete(index, childKey = null) {
+			if (!this.requireSignIn()) return
+			uni.showModal({
+				title: '删除评论',
+				content: '删除后无法恢复,将同时删除其下所有回复。',
+				confirmText: '确认',
+				cancelText: '取消',
+				success: async ({ confirm }) => {
+					if (!confirm) return
+					const parent = this.rows[index]
+					if (!parent) return
+					const target = childKey == null ? parent : (parent.childlist && parent.childlist[childKey])
+					if (!target || !target.id) return
+					try {
+						const result = await deleteClipComment(target.id, target.find_id || this.findId)
+						if (childKey == null) {
+							this.rows.splice(index, 1)
+						} else {
+							parent.childlist.splice(childKey, 1)
+						}
+						const nextCount = result && result.count != null ? result.count : Math.max(0, (this.count || 0) - 1)
+						this.$emit('complete', { type: 'del', count: nextCount, list: this.rows.slice() })
+					} catch (err) {
+						uni.showToast({ title: (err && err.message) || '删除失败', icon: 'none' })
+					}
+				}
+			})
+		}
+	}
+}
+</script>
+
+<style scoped>
+.dcp-root {
+	display: flex;
+	flex-direction: column;
+	flex: 1;
+	min-height: 0;
+	height: 100%;
+	background-color: #ffffff;
+}
+.dcp-list {
+	flex: 1;
+	min-height: 0;
+	padding: 0 28rpx;
+	box-sizing: border-box;
+}
+.dcp-row {
+	display: flex;
+	flex-direction: row;
+	padding-top: 20rpx;
+	padding-bottom: 20rpx;
+}
+.dcp-row-sub {
+	padding-top: 14rpx;
+	padding-bottom: 14rpx;
+}
+.dcp-avatar {
+	width: 70rpx;
+	height: 70rpx;
+	border-radius: 50%;
+	background-color: #eef2f8;
+	margin-right: 20rpx;
+	flex-shrink: 0;
+}
+.dcp-avatar-sub {
+	width: 60rpx;
+	height: 60rpx;
+	margin-right: 16rpx;
+}
+.dcp-body {
+	flex: 1;
+	min-width: 0;
+}
+.dcp-head {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+}
+.dcp-head-left {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	flex: 1;
+	min-width: 0;
+}
+.dcp-rank {
+	height: 32rpx;
+	padding: 0 10rpx;
+	border-radius: 10rpx;
+	margin-right: 12rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.dcp-rank-1 { background-color: #ff8c1a; }
+.dcp-rank-2 { background-color: #aaaa00; }
+.dcp-rank-3 { background-color: #2b6cb0; }
+.dcp-rank-text {
+	color: #ffffff;
+	font-size: 20rpx;
+	line-height: 28rpx;
+}
+.dcp-nick {
+	color: #5c6070;
+	font-size: 28rpx;
+	font-weight: 500;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	max-width: 350rpx;
+}
+.dcp-nick-sub {
+	font-size: 26rpx;
+}
+.dcp-like {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	padding-left: 16rpx;
+	height: 50rpx;
+}
+.dcp-like-num {
+	color: #8a8d9e;
+	font-size: 24rpx;
+	margin-right: 8rpx;
+}
+.dcp-like-num-on {
+	color: #ff4d6a;
+}
+.dcp-like-icon {
+	color: #8a8d9e;
+	font-size: 28rpx;
+}
+.dcp-like-icon-on {
+	color: #ff4d6a;
+}
+.dcp-content {
+	color: #1a1d26;
+	font-size: 28rpx;
+	line-height: 40rpx;
+	margin-top: 6rpx;
+	margin-bottom: 8rpx;
+}
+.dcp-content-sub {
+	font-size: 26rpx;
+	line-height: 36rpx;
+}
+.dcp-meta {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-bottom: 4rpx;
+}
+.dcp-time {
+	color: #b0b4c0;
+	font-size: 22rpx;
+	margin-right: 20rpx;
+}
+.dcp-action {
+	margin-right: 20rpx;
+	padding: 4rpx 0;
+}
+.dcp-action-reply {
+	color: #4a8cff;
+	font-size: 22rpx;
+}
+.dcp-action-del {
+	color: #ff4d6a;
+	font-size: 22rpx;
+}
+.dcp-sublist {
+	background-color: #f5f7fb;
+	border-radius: 12rpx;
+	padding: 6rpx 16rpx;
+	margin-top: 12rpx;
+}
+.dcp-empty {
+	flex: 1;
+	min-height: 0;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+}
+.dcp-empty-title {
+	color: #8a8d9e;
+	font-size: 26rpx;
+}
+.dcp-empty-cta {
+	color: #4a8cff;
+	font-size: 26rpx;
+	margin-top: 8rpx;
+	padding: 8rpx 16rpx;
+}
+.dcp-inputbar {
+	flex-shrink: 0;
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	padding: 16rpx 24rpx;
+	border-top: 1rpx solid rgba(0, 0, 0, 0.06);
+	background-color: #ffffff;
+	box-sizing: border-box;
+}
+.dcp-input {
+	flex: 1;
+	height: 70rpx;
+	background-color: #f5f7fb;
+	color: #1a1d26;
+	border-radius: 35rpx;
+	padding: 0 28rpx;
+	font-size: 28rpx;
+}
+.dcp-send {
+	height: 70rpx;
+	background-color: #4a8cff;
+	border-radius: 35rpx;
+	padding: 0 32rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	margin-left: 20rpx;
+	flex-shrink: 0;
+}
+.dcp-send-text {
+	color: #ffffff;
+	font-size: 26rpx;
+	font-weight: 500;
+}
+</style>

+ 752 - 0
components/dynamics/find-feed-list.vue

@@ -0,0 +1,752 @@
+<template>
+	<scroll-view
+		class="find-scroll"
+		scroll-y
+		:style="{ height: scrollHeight + 'px' }"
+		:show-scrollbar="false"
+		:lower-threshold="120"
+		:refresher-enabled="true"
+		:refresher-triggered="refreshing"
+		refresher-background="#eef3fb"
+		refresher-default-style="black"
+		@refresherrefresh="onRefresherRefresh"
+		@refresherrestore="onRefresherEnd"
+		@refresherabort="onRefresherEnd"
+		@scrolltolower="onLower"
+	>
+		<view v-if="loading && !rows.length" class="find-loading">
+			<view
+				v-for="n in skeletonCount"
+				:key="'sk-' + n"
+				class="find-skeleton-card"
+			>
+				<view class="find-skel-hd">
+					<view class="find-skel-avatar find-skel-shine" />
+					<view class="find-skel-meta">
+						<view class="find-skel-line find-skel-line--name find-skel-shine" />
+						<view class="find-skel-line find-skel-line--sub find-skel-shine" />
+					</view>
+				</view>
+				<view class="find-skel-line find-skel-line--text find-skel-shine" />
+				<view class="find-skel-line find-skel-line--text find-skel-line--short find-skel-shine" />
+				<view class="find-skel-media find-skel-shine" />
+				<view class="find-skel-ft">
+					<view
+						v-for="i in 3"
+						:key="'chip-' + i"
+						class="find-skel-chip find-skel-shine"
+					/>
+				</view>
+			</view>
+			<view class="find-loading-hint">
+				<view class="find-spinner find-spinner--lg" />
+				<text class="find-loading-hint-text">{{ labels.loading }}</text>
+			</view>
+		</view>
+		<view v-else-if="!rows.length" class="find-empty">
+			<view class="find-empty-card">
+				<view class="find-empty-icon-wrap">
+					<text class="find-empty-icon">✦</text>
+				</view>
+				<text class="find-empty-title">{{ labels.empty }}</text>
+				<text class="find-empty-desc">{{ labels.emptyHint }}</text>
+			</view>
+		</view>
+
+		<view
+			v-for="(item, index) in rows"
+			:key="item.id + '_' + index"
+			class="find-card"
+		>
+			<view class="find-card-hd">
+				<view class="find-user" @tap="onUserTap(item)">
+					<image class="find-avatar" :src="avatarOf(item)" mode="aspectFill" />
+					<view class="find-user-meta">
+						<text class="find-nick">{{ nicknameOf(item) }}</text>
+						<view v-if="locationOf(item)" class="find-loc">
+							<text class="find-loc-pin">📍</text>
+							<text class="find-loc-text">{{ locationOf(item) }}</text>
+						</view>
+					</view>
+				</view>
+				<view class="find-bookmark" @tap.stop="onBookmark(item)">
+					<text class="find-bookmark-icon">🔖</text>
+				</view>
+			</view>
+
+			<!-- 正文摘要 -->
+			<view v-if="contentPreview(item)" class="find-content" @tap="openItem(item)">
+				<text v-if="item.type_text" class="find-tag">{{ item.type_text }}</text>
+				<text class="find-content-text">{{ contentPreview(item) }}</text>
+			</view>
+
+			<!-- 视频 -->
+			<view v-if="item.type === 'video'" class="find-media" @tap="openItem(item)">
+				<view class="find-video-wrap">
+					<image class="find-video-cover" :src="videoCoverOf(item)" mode="aspectFill" />
+					<view class="find-play-badge">
+						<text class="find-play-icon">▶</text>
+					</view>
+				</view>
+			</view>
+
+			<!-- 直播 -->
+			<view v-else-if="item.type === 'live'" class="find-media" @tap="openItem(item)">
+				<view class="find-live-wrap">
+					<image class="find-live-cover" :src="liveCoverOf(item)" mode="aspectFill" />
+					<view class="find-live-badge">
+						<text class="find-live-text">{{ liveBadge(item) }}</text>
+					</view>
+				</view>
+			</view>
+
+			<!-- 多图九宫格 -->
+			<view v-else-if="hasImages(item)" class="find-media">
+				<post-image-grid
+					:images="item.images"
+					@tap-image="onImageTap(item, $event)"
+				/>
+			</view>
+
+			<view class="find-card-ft">
+				<view class="find-action" @tap.stop="onLike(index)">
+					<text class="find-like-icon" :class="{ 'find-like-icon--on': item.isLike === 1 }">♥</text>
+					<text class="find-action-num">{{ item.likes || 0 }}</text>
+				</view>
+				<view class="find-action" @tap.stop="openItem(item, 'comments')">
+					<text class="find-comment-icon">💬</text>
+					<text class="find-action-num">{{ item.comments || 0 }}</text>
+				</view>
+				<view class="find-action">
+					<text class="find-view-icon">👁</text>
+					<text class="find-action-num">{{ item.views || 0 }}</text>
+				</view>
+			</view>
+		</view>
+
+		<view v-if="rows.length" class="find-footer">
+			<view v-if="loadingMore" class="find-footer-busy">
+				<view class="find-spinner" />
+				<text class="find-footer-text">{{ labels.loading }}</text>
+			</view>
+			<text v-else-if="footerText" class="find-footer-text">{{ footerText }}</text>
+		</view>
+
+		<view class="find-scroll-pad" :style="{ height: bottomInset + 'px' }" />
+	</scroll-view>
+</template>
+
+<script>
+import PostImageGrid from '@/components/dynamics/post-image-grid.vue'
+import { stripHtml } from '@/features/feed/clip-normalize'
+import { detailPageUrl, videoDetailUrl } from '@/features/dynamics/post-media'
+import {
+	fetchDiscoverFeedPage,
+	toggleClipLike
+} from '@/features/feed/feed-gateway'
+
+export default {
+	name: 'FindFeedList',
+	components: { PostImageGrid },
+	props: {
+		scrollHeight: { type: Number, default: 500 },
+		bottomInset: { type: Number, default: 0 },
+		keyword: { type: String, default: '' },
+		emptyText: { type: String, default: '' },
+		loadingText: { type: String, default: '' },
+		noMoreText: { type: String, default: '' },
+		guestLabel: { type: String, default: '' },
+		loadFailedText: { type: String, default: '' },
+		actionFailedText: { type: String, default: '' },
+		contentUnavailableText: { type: String, default: '' },
+		userProfileDevText: { type: String, default: '' },
+		bookmarkDevText: { type: String, default: '' },
+		liveReplayText: { type: String, default: '' }
+	},
+	data() {
+		return {
+			rows: [],
+			pageNo: 1,
+			pageEnd: 1,
+			loading: false,
+			loadingMore: false,
+			refreshing: false,
+			status: 'more',
+			skeletonCount: 3
+		}
+	},
+	computed: {
+		labels() {
+			const t = this.$t.bind(this)
+			return {
+				loading: this.loadingText || t('dynamics.loading'),
+				noMore: this.noMoreText || t('dynamics.noMore'),
+				empty: this.emptyText || t('dynamics.emptyFeed'),
+				emptyHint: t('dynamics.emptyFeedHint'),
+				guest: this.guestLabel || t('dynamics.guest'),
+				loadFailed: this.loadFailedText || t('dynamics.loadFailed'),
+				actionFailed: this.actionFailedText || t('dynamics.actionFailed'),
+				contentUnavailable: this.contentUnavailableText || t('dynamics.contentUnavailable'),
+				userProfileDev: this.userProfileDevText || t('dynamics.userProfileDev'),
+				bookmarkDev: this.bookmarkDevText || t('dynamics.bookmarkDev'),
+				liveReplay: this.liveReplayText || t('dynamics.liveReplay')
+			}
+		},
+		signedIn() {
+			return !!(this.$store && this.$store.state && this.$store.state.user && this.$store.state.user.isLogin)
+		},
+		footerText() {
+			if (this.loadingMore) return this.labels.loading
+			if (this.status === 'noMore') return this.labels.noMore
+			return ''
+		}
+	},
+	watch: {
+		keyword() {
+			this.reload()
+		}
+	},
+	mounted() {
+		this.reload()
+	},
+	methods: {
+		oss(url, w = 400, h = 0) {
+			if (this.$appKit && typeof this.$appKit.oss === 'function') {
+				return this.$appKit.oss(url, w, h)
+			}
+			return url || ''
+		},
+		nicknameOf(item) {
+			if (item.shop && item.shop.shopname) return item.shop.shopname
+			if (item.user && item.user.nickname) return item.user.nickname
+			return item.username || this.labels.guest
+		},
+		avatarOf(item) {
+			if (item.shop && item.shop.avatar) return this.oss(item.shop.avatar, 72, 72)
+			if (item.user && item.user.avatar) return this.oss(item.user.avatar, 72, 72)
+			return this.oss('', 72, 72)
+		},
+		locationOf(item) {
+			return item.city || item.address || item.region || item.location || ''
+		},
+		hasImages(item) {
+			return Array.isArray(item.images) && item.images.length > 0
+		},
+		contentPreview(item) {
+			const text = stripHtml(item.content)
+			if (!text) return ''
+			return text.length > 120 ? `${text.slice(0, 120)}…` : text
+		},
+		videoCoverOf(item) {
+			const cover = (item.video && item.video.cover_url) || (item.images && item.images[0]) || ''
+			return this.oss(cover, 750, 0)
+		},
+		liveCoverOf(item) {
+			const cover = (item.images && item.images[0]) || ''
+			return this.oss(cover, 750, 0)
+		},
+		liveBadge(item) {
+			if (item.live && String(item.live.state) === '1') return 'LIVE'
+			return this.labels.liveReplay
+		},
+		async reload(options = {}) {
+			const keepRows = !!(options && options.keepRows)
+			this.pageNo = 1
+			this.pageEnd = 1
+			if (!keepRows) this.rows = []
+			this.status = 'more'
+			await this.loadPage('refresh', { keepRows })
+		},
+		/** 下拉刷新:保留当前列表,避免闪屏 */
+		async pullRefresh() {
+			if (this.refreshing) return
+			this.refreshing = true
+			try {
+				await this.reload({ keepRows: true })
+			} finally {
+				this.refreshing = false
+				try {
+					uni.stopPullDownRefresh()
+				} catch (_) {}
+			}
+		},
+		onRefresherRefresh() {
+			this.pullRefresh()
+		},
+		onRefresherEnd() {
+			this.refreshing = false
+		},
+		onLower() {
+			if (this.loading || this.loadingMore || this.status === 'noMore') return
+			this.loadPage('lower')
+		},
+		async loadPage(mode, options = {}) {
+			const keepRows = options.keepRows === true
+			if (mode === 'lower') {
+				if (this.pageNo >= this.pageEnd) {
+					this.status = 'noMore'
+					return
+				}
+				this.loadingMore = true
+			} else if (!keepRows) {
+				this.loading = true
+			}
+
+			const nextPage = mode === 'lower' ? this.pageNo + 1 : 1
+			const filters = {}
+			if (this.keyword && String(this.keyword).trim()) {
+				filters.search = String(this.keyword).trim()
+			}
+			try {
+				const pack = await fetchDiscoverFeedPage(nextPage, filters)
+				const list = pack.rows || []
+				this.pageNo = pack.currentPage || nextPage
+				this.pageEnd = pack.lastPage || this.pageNo
+				this.rows = mode === 'lower' ? this.rows.concat(list) : list
+				this.status = this.pageNo >= this.pageEnd || !list.length ? 'noMore' : 'more'
+			} catch (err) {
+				if (!this.rows.length) this.status = 'noMore'
+				this.$appKit && this.$appKit.msg(err.message || this.labels.loadFailed)
+			} finally {
+				this.loading = false
+				this.loadingMore = false
+				if (mode === 'refresh') {
+					try { uni.stopPullDownRefresh() } catch (_) {}
+				}
+			}
+		},
+		onUserTap(item) {
+			if (!item) return
+			const userNo =
+				item.user_no ||
+				(item.user && (item.user.user_no || item.user.id))
+			if (!userNo) return
+			uni.navigateTo({
+				url: `/pages/profile/show?id=${encodeURIComponent(String(userNo))}`
+			})
+		},
+		onBookmark(item) {
+			// this.$appKit && this.$appKit.msg(this.labels.bookmarkDev)
+		},
+		async onLike(index) {
+			if (!this.$appKit || !this.$appKit.requireLogin()) return
+			const row = this.rows[index]
+			if (!row || !row.id) return
+			try {
+				const flag = await toggleClipLike(row.id)
+				if (flag === 0 || flag === '0') {
+					row.isLike = 0
+					row.likes = Math.max(0, Number(row.likes || 0) - 1)
+				} else if (flag === 1 || flag === '1') {
+					row.isLike = 1
+					row.likes = Number(row.likes || 0) + 1
+				}
+			} catch (err) {
+				this.$appKit && this.$appKit.msg(err.message || this.labels.actionFailed)
+			}
+		},
+		onImageTap(item, imgIndex) {
+			this.openItem(item, '', imgIndex)
+		},
+		openItem(item, scene, imgIndex = 0) {
+			if (item.state && item.state !== 'normal') {
+				this.$appKit && this.$appKit.msg(this.labels.contentUnavailable)
+				return
+			}
+			if (item.type === 'live' && item.live_id) {
+				this.$appKit && this.$appKit.auth(`/pages/live/watch?id=${item.live_id}`)
+				return
+			}
+			if (item.type === 'video') {
+				uni.navigateTo({
+					url: videoDetailUrl(item.id, { labelId: item.label_id })
+				})
+				return
+			}
+			const url = detailPageUrl(item.id, {
+				focus: scene === 'comments' ? 'comments' : '',
+				img: imgIndex
+			})
+			uni.navigateTo({ url })
+		}
+	}
+}
+</script>
+
+<style scoped>
+.find-scroll {
+	width: 100%;
+}
+.find-loading {
+	padding: 8rpx 0 24rpx;
+}
+
+.find-skeleton-card {
+	margin: 0 32rpx 28rpx;
+	padding: 28rpx;
+	background-color: #ffffff;
+	border-radius: 28rpx;
+	box-shadow: 0 8rpx 32rpx rgba(74, 140, 255, 0.08);
+}
+
+.find-skel-hd {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-bottom: 24rpx;
+}
+
+.find-skel-avatar {
+	width: 72rpx;
+	height: 72rpx;
+	border-radius: 36rpx;
+	flex-shrink: 0;
+}
+
+.find-skel-meta {
+	flex: 1;
+	margin-left: 20rpx;
+}
+
+.find-skel-line {
+	height: 24rpx;
+	border-radius: 12rpx;
+	margin-bottom: 12rpx;
+}
+
+.find-skel-line--name {
+	width: 42%;
+	height: 28rpx;
+}
+
+.find-skel-line--sub {
+	width: 28%;
+	margin-bottom: 0;
+}
+
+.find-skel-line--text {
+	width: 100%;
+}
+
+.find-skel-line--short {
+	width: 72%;
+	margin-bottom: 20rpx;
+}
+
+.find-skel-media {
+	width: 100%;
+	height: 320rpx;
+	border-radius: 20rpx;
+	margin-bottom: 24rpx;
+}
+
+.find-skel-ft {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+}
+
+.find-skel-chip {
+	width: 88rpx;
+	height: 28rpx;
+	border-radius: 14rpx;
+	margin-right: 24rpx;
+}
+
+.find-skel-shine {
+	background: linear-gradient(
+		90deg,
+		#e8edf5 0%,
+		#f4f7fc 45%,
+		#e8edf5 90%
+	);
+	background-size: 200% 100%;
+	animation: find-skel-shine 1.4s ease-in-out infinite;
+}
+
+@keyframes find-skel-shine {
+	0% {
+		background-position: 100% 0;
+	}
+	100% {
+		background-position: -100% 0;
+	}
+}
+
+.find-loading-hint {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: center;
+	padding: 8rpx 0 32rpx;
+}
+
+.find-loading-hint-text {
+	margin-left: 16rpx;
+	font-size: 26rpx;
+	color: #8a8d9e;
+	letter-spacing: 1rpx;
+}
+
+.find-empty {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	padding: 48rpx 32rpx 80rpx;
+	min-height: 360rpx;
+	box-sizing: border-box;
+}
+
+.find-empty-card {
+	width: 100%;
+	padding: 56rpx 40rpx;
+	background-color: #ffffff;
+	border-radius: 28rpx;
+	box-shadow: 0 8rpx 32rpx rgba(74, 140, 255, 0.08);
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+}
+
+.find-empty-icon-wrap {
+	width: 112rpx;
+	height: 112rpx;
+	border-radius: 56rpx;
+	background: linear-gradient(135deg, rgba(74, 140, 255, 0.14) 0%, rgba(110, 200, 255, 0.08) 100%);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	margin-bottom: 28rpx;
+}
+
+.find-empty-icon {
+	font-size: 48rpx;
+	color: #4a8cff;
+	font-weight: 700;
+}
+
+.find-empty-title {
+	font-size: 30rpx;
+	font-weight: 700;
+	color: #1a1d26;
+	text-align: center;
+}
+
+.find-empty-desc {
+	margin-top: 12rpx;
+	font-size: 26rpx;
+	color: #8a8d9e;
+	text-align: center;
+	line-height: 1.5;
+}
+
+.find-spinner {
+	width: 28rpx;
+	height: 28rpx;
+	border: 4rpx solid rgba(74, 140, 255, 0.18);
+	border-top-color: #4a8cff;
+	border-radius: 50%;
+	animation: find-spin 0.75s linear infinite;
+	flex-shrink: 0;
+}
+
+.find-spinner--lg {
+	width: 36rpx;
+	height: 36rpx;
+	border-width: 5rpx;
+}
+
+@keyframes find-spin {
+	to {
+		transform: rotate(360deg);
+	}
+}
+
+.find-footer-busy {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: center;
+	padding: 8rpx 0;
+}
+.find-card {
+	margin: 0 32rpx 28rpx;
+	padding: 28rpx;
+	background-color: #ffffff;
+	border-radius: 28rpx;
+	box-shadow: 0 8rpx 32rpx rgba(74, 140, 255, 0.08);
+}
+.find-card-hd {
+	display: flex;
+	flex-direction: row;
+	align-items: flex-start;
+	justify-content: space-between;
+	margin-bottom: 20rpx;
+}
+.find-user {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	flex: 1;
+	min-width: 0;
+}
+.find-avatar {
+	width: 72rpx;
+	height: 72rpx;
+	border-radius: 36rpx;
+	background-color: #eef2f8;
+	flex-shrink: 0;
+}
+.find-user-meta {
+	margin-left: 20rpx;
+	flex: 1;
+	min-width: 0;
+}
+.find-nick {
+	font-size: 30rpx;
+	font-weight: 700;
+	color: #1a1d26;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.find-loc {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-top: 6rpx;
+}
+.find-loc-pin {
+	font-size: 22rpx;
+	margin-right: 6rpx;
+}
+.find-loc-text {
+	font-size: 24rpx;
+	color: #8a8d9e;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.find-bookmark {
+	width: 64rpx;
+	height: 64rpx;
+	align-items: center;
+	justify-content: center;
+	flex-shrink: 0;
+}
+.find-bookmark-icon {
+	font-size: 36rpx;
+	opacity: 0.55;
+}
+.find-content {
+	margin-bottom: 20rpx;
+}
+.find-tag {
+	display: inline-block;
+	font-size: 22rpx;
+	color: #4a8cff;
+	background-color: rgba(74, 140, 255, 0.12);
+	padding: 4rpx 12rpx;
+	border-radius: 8rpx;
+	margin-right: 8rpx;
+}
+.find-content-text {
+	font-size: 28rpx;
+	line-height: 42rpx;
+	color: #3d4250;
+}
+.find-media {
+	margin-bottom: 4rpx;
+}
+.find-video-wrap,
+.find-live-wrap {
+	position: relative;
+	width: 100%;
+	height: 420rpx;
+	border-radius: 20rpx;
+	overflow: hidden;
+	background-color: #eef2f8;
+}
+.find-video-cover,
+.find-live-cover {
+	width: 100%;
+	height: 100%;
+}
+.find-play-badge,
+.find-live-badge {
+	position: absolute;
+	left: 50%;
+	top: 50%;
+	transform: translate(-50%, -50%);
+}
+.find-play-badge {
+	width: 88rpx;
+	height: 88rpx;
+	border-radius: 44rpx;
+	background-color: rgba(0, 0, 0, 0.35);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.find-play-icon {
+	color: #ffffff;
+	font-size: 32rpx;
+	margin-left: 6rpx;
+}
+.find-live-badge {
+	padding: 0 24rpx;
+	height: 56rpx;
+	border-radius: 999rpx;
+	background-color: rgba(255, 77, 79, 0.88);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.find-live-text {
+	color: #ffffff;
+	font-size: 24rpx;
+	font-weight: 700;
+}
+.find-card-ft {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-top: 24rpx;
+}
+.find-action {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-right: 36rpx;
+}
+.find-like-icon,
+.find-comment-icon,
+.find-view-icon {
+	font-size: 30rpx;
+	color: #b0b4c0;
+	margin-right: 8rpx;
+}
+.find-like-icon--on {
+	color: #ff4d4f;
+}
+.find-action-num {
+	font-size: 26rpx;
+	color: #5c6070;
+}
+.find-footer {
+	padding: 16rpx 0 8rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.find-footer-busy .find-footer-text {
+	margin-left: 12rpx;
+}
+
+.find-footer-text {
+	font-size: 24rpx;
+	color: #b0b4c0;
+	text-align: center;
+}
+</style>

+ 171 - 0
components/dynamics/post-card.vue

@@ -0,0 +1,171 @@
+<template>
+	<view class="pcard" @tap="$emit('open', post)">
+		<view class="pcard-hd">
+			<image class="pcard-avatar" :src="avatarUrl" mode="aspectFill" />
+			<view class="pcard-meta">
+				<text class="pcard-nick">{{ nickname }}</text>
+				<text class="pcard-time">{{ timeText }}</text>
+			</view>
+			<view
+				class="pcard-follow"
+				:class="{ 'pcard-follow--on': isFollowed }"
+				@tap.stop="$emit('follow', post)"
+			>
+				<text class="pcard-follow-text">{{ isFollowed ? copy.followed : copy.followPlus }}</text>
+			</view>
+		</view>
+
+		<post-content-block
+			:post="post"
+			@open-video="$emit('open-video', $event)"
+			@open-detail="$emit('open-detail', $event)"
+		/>
+
+		<view class="pcard-ft">
+			<view class="pcard-action" @tap.stop="$emit('like', post)">
+				<text class="pcard-like" :class="{ 'pcard-like--on': post.isLike === 1 }">♥</text>
+				<text class="pcard-num">{{ post.likes || 0 }}</text>
+			</view>
+			<view class="pcard-action" @tap.stop="$emit('comment', post)">
+				<text class="pcard-comment">💬</text>
+				<text class="pcard-num">{{ post.comments || 0 }}</text>
+			</view>
+			<view class="pcard-action">
+				<text class="pcard-view">👁</text>
+				<text class="pcard-num">{{ post.views || 0 }}</text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import PostContentBlock from '@/components/dynamics/post-content-block.vue'
+
+export default {
+	name: 'PostCard',
+	components: { PostContentBlock },
+	props: {
+		post: { type: Object, default: () => ({}) }
+	},
+	computed: {
+		copy() {
+			const t = this.$t.bind(this)
+			return {
+				followPlus: t('dynamics.followPlus'),
+				followed: t('dynamics.followed'),
+				guest: t('dynamics.guest')
+			}
+		},
+		nickname() {
+			if (this.post.shop && this.post.shop.shopname) return this.post.shop.shopname
+			if (this.post.user && this.post.user.nickname) return this.post.user.nickname
+			return this.copy.guest
+		},
+		avatarUrl() {
+			const path = (this.post.shop && this.post.shop.avatar)
+				|| (this.post.user && this.post.user.avatar)
+				|| ''
+			if (this.$appKit && typeof this.$appKit.oss === 'function') {
+				return this.$appKit.oss(path, 72, 72)
+			}
+			return path
+		},
+		timeText() {
+			if (this.$appKit && typeof this.$appKit.timeFormat === 'function') {
+				return this.$appKit.timeFormat(this.post.createtime, 'yyyy-mm-dd') || ''
+			}
+			return ''
+		},
+		isFollowed() {
+			const flag = this.post && this.post.isFollow
+			return flag === 1 || flag === '1' || flag === true
+		}
+	}
+}
+</script>
+
+<style scoped>
+.pcard {
+	margin: 0 32rpx 28rpx;
+	padding: 28rpx;
+	background-color: #ffffff;
+	border-radius: 28rpx;
+	box-shadow: 0 8rpx 32rpx rgba(74, 140, 255, 0.08);
+}
+.pcard-hd {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-bottom: 24rpx;
+}
+.pcard-avatar {
+	width: 72rpx;
+	height: 72rpx;
+	border-radius: 36rpx;
+	background-color: #eef2f8;
+	flex-shrink: 0;
+}
+.pcard-meta {
+	flex: 1;
+	min-width: 0;
+	margin-left: 20rpx;
+}
+.pcard-nick {
+	font-size: 30rpx;
+	font-weight: 700;
+	color: #1a1d26;
+	display: block;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.pcard-time {
+	font-size: 24rpx;
+	color: #8a8d9e;
+	margin-top: 4rpx;
+}
+.pcard-follow {
+	padding: 8rpx 20rpx;
+	border-radius: 999rpx;
+	background-color: #4a8cff;
+	flex-shrink: 0;
+}
+.pcard-follow--on {
+	background-color: #eef2f8;
+}
+.pcard-follow-text {
+	font-size: 22rpx;
+	color: #ffffff;
+}
+.pcard-follow--on .pcard-follow-text {
+	color: #8a8d9e;
+}
+.pcard-ft {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-top: 24rpx;
+	padding-top: 20rpx;
+	border-top: 1rpx solid rgba(0, 0, 0, 0.05);
+}
+.pcard-action {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-right: 36rpx;
+}
+.pcard-like,
+.pcard-comment,
+.pcard-view {
+	font-size: 30rpx;
+	margin-right: 8rpx;
+	color: #b0b4c0;
+}
+.pcard-like--on {
+	color: #ff4d4f;
+}
+.pcard-num {
+	font-size: 26rpx;
+	color: #5c6070;
+}
+</style>

+ 244 - 0
components/dynamics/post-content-block.vue

@@ -0,0 +1,244 @@
+<template>
+	<view class="pcb">
+		<!-- 上新 -->
+		<view v-if="post.type === 'new'" class="pcb-new">
+			<view class="pcb-date-badge">
+				<text class="pcb-date-day">{{ post.createtime_date || '—' }}</text>
+			</view>
+			<text class="pcb-new-label">今日上新</text>
+		</view>
+
+		<!-- 图文九宫格(列表 + 详情) -->
+		<post-image-grid
+			v-if="showGrid"
+			:images="post.images"
+			:max="gridMax"
+			@tap-image="onGridTap"
+		/>
+
+		<!-- 视频缩略 -->
+		<view v-if="post.type === 'video'" class="pcb-video" @tap="$emit('open-video', post)">
+			<image class="pcb-video-cover" :src="videoCover" mode="aspectFill" />
+			<view class="pcb-video-play">
+				<text class="pcb-video-play-icon">▶</text>
+			</view>
+		</view>
+
+		<!-- 关联商品 -->
+		<scroll-view
+			v-if="post.goods && post.goods.length"
+			class="pcb-goods-scroll"
+			scroll-x
+			:show-scrollbar="false"
+		>
+			<view
+				v-for="goods in post.goods"
+				:key="goods.id"
+				class="pcb-goods-item"
+				:class="{ 'pcb-goods-item--solo': post.goods.length === 1 }"
+				@tap="openGoods(goods.id)"
+			>
+				<image class="pcb-goods-img" :src="oss(goods.image, 100, 100)" mode="aspectFill" />
+				<view class="pcb-goods-meta">
+					<text class="pcb-goods-title">{{ goods.title }}</text>
+					<text class="pcb-goods-price">¥{{ goods.price }}</text>
+				</view>
+			</view>
+		</scroll-view>
+
+		<!-- 正文 -->
+		<view v-if="htmlContent" class="pcb-body">
+			<rich-text v-if="useRichText" :nodes="htmlContent" />
+			<text v-else class="pcb-body-text">{{ plainContent }}</text>
+		</view>
+	</view>
+</template>
+
+<script>
+import PostImageGrid from '@/components/dynamics/post-image-grid.vue'
+import { stripHtml } from '@/features/feed/clip-normalize'
+import { absolutizePreviewUrls, GRID_MAX } from '@/features/dynamics/post-media'
+
+export default {
+	name: 'PostContentBlock',
+	components: { PostImageGrid },
+	props: {
+		post: { type: Object, default: () => ({}) },
+		isMain: { type: Boolean, default: false }
+	},
+	computed: {
+		hasImages() {
+			return Array.isArray(this.post.images) && this.post.images.length > 0
+		},
+		showGrid() {
+			return this.post.type !== 'video' && this.hasImages
+		},
+		gridMax() {
+			return GRID_MAX
+		},
+		videoCover() {
+			const cover = (this.post.video && this.post.video.cover_url)
+				|| (this.post.images && this.post.images[0])
+				|| ''
+			return this.oss(cover, 750, 0)
+		},
+		htmlContent() {
+			return this.post.content || ''
+		},
+		plainContent() {
+			return stripHtml(this.post.content)
+		},
+		useRichText() {
+			return /<[^>]+>/.test(String(this.post.content || ''))
+		}
+	},
+	methods: {
+		oss(url, w = 400, h = 0) {
+			if (this.$appKit && typeof this.$appKit.oss === 'function') {
+				return this.$appKit.oss(url, w, h)
+			}
+			return url || ''
+		},
+		onGridTap(index) {
+			if (this.isMain) {
+				this.previewImages(index)
+				return
+			}
+			this.$emit('open-detail', { id: this.post.id, img: index })
+		},
+		previewImages(index) {
+			const urls = absolutizePreviewUrls(this.post.images, (u) => this.oss(u, 750, 0))
+			if (!urls.length) return
+			uni.previewImage({
+				current: urls[index] || urls[0],
+				urls
+			})
+		},
+		openGoods(id) {
+			if (typeof this.$appKit.onGoods === 'function') {
+				this.$appKit.onGoods(id)
+				return
+			}
+			uni.navigateTo({ url: `/pages/product/goods?id=${id}` })
+		}
+	}
+}
+</script>
+
+<style scoped>
+.pcb {
+	width: 100%;
+}
+.pcb-new {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-bottom: 20rpx;
+}
+.pcb-date-badge {
+	width: 72rpx;
+	height: 72rpx;
+	border-radius: 18rpx;
+	background: linear-gradient(135deg, #4a8cff 0%, #6ec8ff 100%);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	margin-right: 16rpx;
+}
+.pcb-date-day {
+	color: #ffffff;
+	font-size: 22rpx;
+	font-weight: 700;
+	text-align: center;
+	line-height: 1.2;
+}
+.pcb-new-label {
+	font-size: 30rpx;
+	font-weight: 700;
+	color: #1a1d26;
+}
+.pcb-video {
+	position: relative;
+	width: 62%;
+	height: 520rpx;
+	border-radius: 20rpx;
+	overflow: hidden;
+	background-color: #1a1d26;
+}
+.pcb-video-cover {
+	width: 100%;
+	height: 100%;
+}
+.pcb-video-play {
+	position: absolute;
+	left: 50%;
+	top: 50%;
+	transform: translate(-50%, -50%);
+	width: 88rpx;
+	height: 88rpx;
+	border-radius: 44rpx;
+	background-color: rgba(255, 255, 255, 0.25);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.pcb-video-play-icon {
+	color: #ffffff;
+	font-size: 32rpx;
+	margin-left: 6rpx;
+}
+.pcb-goods-scroll {
+	width: 100%;
+	white-space: nowrap;
+	margin-top: 24rpx;
+}
+.pcb-goods-item {
+	display: inline-flex;
+	flex-direction: row;
+	align-items: center;
+	width: 420rpx;
+	margin-right: 16rpx;
+	padding: 12rpx;
+	border-radius: 16rpx;
+	background-color: #f5f7fb;
+	vertical-align: top;
+}
+.pcb-goods-item--solo {
+	width: 100%;
+	margin-right: 0;
+}
+.pcb-goods-img {
+	width: 96rpx;
+	height: 96rpx;
+	border-radius: 12rpx;
+	background-color: #eef2f8;
+	flex-shrink: 0;
+}
+.pcb-goods-meta {
+	flex: 1;
+	min-width: 0;
+	margin-left: 16rpx;
+}
+.pcb-goods-title {
+	font-size: 26rpx;
+	color: #1a1d26;
+	display: block;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.pcb-goods-price {
+	font-size: 26rpx;
+	color: #ff6b35;
+	font-weight: 700;
+	margin-top: 8rpx;
+}
+.pcb-body {
+	margin-top: 24rpx;
+}
+.pcb-body-text {
+	font-size: 28rpx;
+	line-height: 44rpx;
+	color: #3d4250;
+}
+</style>

+ 137 - 0
components/dynamics/post-image-grid.vue

@@ -0,0 +1,137 @@
+<template>
+	<view class="pig" :class="'pig--' + columns">
+		<!-- 单图 -->
+		<view
+			v-if="columns === 1"
+			class="pig-single"
+			@tap.stop="onTap(0)"
+		>
+			<image class="pig-single-img" :src="cellSrc(visible[0], 750)" mode="aspectFill" />
+		</view>
+
+		<!-- 多图网格 -->
+		<view v-else class="pig-grid">
+			<view
+				v-for="(img, idx) in visible"
+				:key="idx"
+				class="pig-cell"
+				:class="'pig-cell--' + columns"
+				@tap.stop="onTap(idx)"
+			>
+				<image class="pig-cell-img" :src="cellSrc(img, 360)" mode="aspectFill" />
+				<view v-if="idx === visible.length - 1 && overflow > 0" class="pig-overflow">
+					<text class="pig-overflow-text">+{{ overflow }}</text>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import { gridColumnCount, gridDisplayImages } from '@/features/dynamics/post-media'
+
+export default {
+	name: 'PostImageGrid',
+	props: {
+		images: { type: Array, default: () => [] },
+		max: { type: Number, default: 9 }
+	},
+	computed: {
+		pack() {
+			return gridDisplayImages(this.images, this.max)
+		},
+		visible() {
+			return this.pack.visible
+		},
+		overflow() {
+			return this.pack.overflow
+		},
+		count() {
+			return this.visible.length
+		},
+		columns() {
+			return gridColumnCount(this.count)
+		}
+	},
+	methods: {
+		oss(url, w = 400, h = 0) {
+			if (this.$appKit && typeof this.$appKit.oss === 'function') {
+				return this.$appKit.oss(url, w, h)
+			}
+			return url || ''
+		},
+		cellSrc(url, w) {
+			return this.oss(url, w, w)
+		},
+		onTap(index) {
+			this.$emit('tap-image', index)
+		}
+	}
+}
+</script>
+
+<style scoped>
+.pig {
+	width: 100%;
+}
+.pig-single {
+	width: 100%;
+	height: 420rpx;
+	border-radius: 20rpx;
+	overflow: hidden;
+	background-color: #eef2f8;
+}
+.pig-single-img {
+	width: 100%;
+	height: 100%;
+}
+.pig-grid {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: wrap;
+	width: 100%;
+	justify-content: flex-start;
+}
+.pig-cell {
+	position: relative;
+	border-radius: 12rpx;
+	overflow: hidden;
+	background-color: #eef2f8;
+	margin-bottom: 8rpx;
+}
+.pig-cell--2 {
+	width: 49%;
+	height: 280rpx;
+}
+.pig-cell--2:nth-child(2n) {
+	margin-left: 2%;
+}
+.pig-cell--3 {
+	width: 32%;
+	height: 210rpx;
+	margin-right: 2%;
+}
+.pig-cell--3:nth-child(3n) {
+	margin-right: 0;
+}
+.pig-cell-img {
+	width: 100%;
+	height: 100%;
+}
+.pig-overflow {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.45);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.pig-overflow-text {
+	color: #ffffff;
+	font-size: 40rpx;
+	font-weight: 700;
+}
+</style>

+ 46 - 0
components/empty/empty.vue

@@ -0,0 +1,46 @@
+<template>
+	<view class="hl88-empty-wrap">
+		<view class="wanl-gray text-center">
+			<image class="animation-scale-down" :src="src ? $appKit.appstc('/default/' + src + '.png') : $appKit.appstc('/default/default3x.png')"></image>
+			<view class="text-30">{{ text }}</view>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * WanlEmpty 缺省页
+ *
+ * @property {String} src 图片地址名
+ * @property {String} text 提示文字
+ * @example <empty src="" text=""/>
+ */
+export default {
+	name: 'Empty',
+	props: {
+		src: {
+			type: String,
+			default: ''
+		},
+		text: {
+			type: String,
+			default: 'No data'
+		}
+	}
+};
+</script>
+
+<style>
+	.empty-content {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		width: 100%;
+		padding: 200rpx 130rpx;
+	}
+	.hl88-empty-wrap image{
+		width: 320rpx;
+		height: 320rpx;
+	}
+</style>
+

+ 618 - 0
components/feed-comment/comment-thread.vue

@@ -0,0 +1,618 @@
+<template>
+	<view class="ct-root">
+		<!-- 评论列表 -->
+		<scroll-view
+			v-if="rows.length > 0"
+			class="ct-list"
+			:style="{ height: listHeight + 'px' }"
+			scroll-y
+		>
+			<view
+				v-for="(item, index) in rows"
+				:key="(item && item.id) || index"
+				class="ct-row"
+			>
+				<image
+					class="ct-avatar"
+					:src="avatarSrc(item && item.user && item.user.avatar)"
+					@click.stop="onUser(item)"
+				/>
+				<view class="ct-body">
+					<!-- 父评论:昵称 + 点赞 -->
+					<view class="ct-head">
+						<view class="ct-head-left">
+							<view v-if="index === 0" class="ct-rank ct-rank-1">
+								<text class="ct-rank-text">第一</text>
+							</view>
+							<view v-else-if="index === 1" class="ct-rank ct-rank-2">
+								<text class="ct-rank-text">第二</text>
+							</view>
+							<view v-else-if="index === 2" class="ct-rank ct-rank-3">
+								<text class="ct-rank-text">第三</text>
+							</view>
+							<text class="ct-nick">{{ (item.user && item.user.nickname) || '游客' }}</text>
+						</view>
+						<view class="ct-like" @click.stop="onLike(index)">
+							<text :class="item.hasLike ? 'ct-like-num ct-like-num-on' : 'ct-like-num'">{{ item.like || 0 }}</text>
+							<text :class="item.hasLike ? 'ct-like-icon ct-like-icon-on' : 'ct-like-icon'">{{ item.hasLike ? '♥' : '♡' }}</text>
+						</view>
+					</view>
+
+					<!-- 父评论:内容 -->
+					<text class="ct-content">{{ plain(item.content) }}</text>
+
+					<!-- 父评论:时间 + 回复 + 删除 -->
+					<view class="ct-meta">
+						<text class="ct-time">{{ item.createtime_text || '' }}</text>
+						<view
+							class="ct-action"
+							@click.stop="onReply(item.user && item.user.nickname, item.user && item.user.nickname, item.id)"
+						>
+							<text class="ct-action-reply">回复</text>
+						</view>
+						<view v-if="item.owner" class="ct-action" @click.stop="onDelete(index)">
+							<text class="ct-action-del">删除</text>
+						</view>
+					</view>
+
+					<!-- 子评论列表 -->
+					<view v-if="item.childlist && item.childlist.length > 0" class="ct-sublist">
+						<view
+							v-for="(child, k) in item.childlist"
+							:key="(child && child.id) || k"
+							class="ct-row ct-row-sub"
+						>
+							<image
+								class="ct-avatar ct-avatar-sub"
+								:src="avatarSrc(child && child.user && child.user.avatar)"
+								@click.stop="onUser(child)"
+							/>
+							<view class="ct-body">
+								<view class="ct-head">
+									<view class="ct-head-left">
+										<text class="ct-nick ct-nick-sub">{{ (child.user && child.user.nickname) || '游客' }}</text>
+									</view>
+									<view class="ct-like" @click.stop="onLike(index, k)">
+										<text :class="child.hasLike ? 'ct-like-num ct-like-num-on' : 'ct-like-num'">{{ child.like || 0 }}</text>
+										<text :class="child.hasLike ? 'ct-like-icon ct-like-icon-on' : 'ct-like-icon'">{{ child.hasLike ? '♥' : '♡' }}</text>
+									</view>
+								</view>
+								<text class="ct-content ct-content-sub">{{ plain(child.content) }}</text>
+								<view class="ct-meta">
+									<text class="ct-time">{{ child.createtime_text || '' }}</text>
+									<view
+										class="ct-action"
+										@click.stop="onReply(item.user && item.user.nickname, child.user && child.user.nickname, item.id)"
+									>
+										<text class="ct-action-reply">回复</text>
+									</view>
+									<view v-if="child.owner" class="ct-action" @click.stop="onDelete(index, k)">
+										<text class="ct-action-del">删除</text>
+									</view>
+								</view>
+							</view>
+						</view>
+					</view>
+				</view>
+			</view>
+		</scroll-view>
+
+		<!-- 空状态 -->
+		<view v-else class="ct-empty" :style="{ height: listHeight + 'px' }">
+			<text class="ct-empty-title">暂无评论,</text>
+			<text class="ct-empty-cta" @click.stop="focusInput">来写第一条 →</text>
+		</view>
+
+		<!-- 底部输入栏:fixed + 键盘跟随(照搬 HKLive comment-nvue.vue) -->
+		<view class="ct-inputbar" :style="inputBarStyle" @click.stop>
+			<input
+				ref="commentInput"
+				class="ct-input"
+				v-model="form.content"
+				:placeholder="placeholder"
+				placeholder-style="color: #999;"
+				:maxlength="200"
+				adjust-position="false"
+				:cursor-spacing="0"
+				confirm-type="send"
+				@blur="onInputBlur"
+				@confirm="onSubmit"
+			/>
+			<view class="ct-send" @click.stop="onSubmit">
+				<text class="ct-send-text">发送</text>
+			</view>
+		</view>
+		<view :style="{ height: spacerHeight + 'px' }" />
+	</view>
+</template>
+
+<script>
+/**
+ * 视频评论弹层主体
+ *
+ * 移植自参考工程 HKLive\components\comment\comment-nvue.vue,做了精简:
+ *   - 移除 i18n(直接中文文案)
+ *   - 移除举报跳转(NewHKLive 暂无 complaint 路由)
+ *   - 移除 $appKit / $wanlshop 等私有插件依赖
+ *   - 头像加载用 NewHKLive 的 buildOssUrl
+ *   - 接口走 features/feed/feed-gateway.js 的封装
+ *
+ * 接口:
+ *   - 列表已由父组件传入 props.list(父组件用 fetchClipComments 拉好)
+ *   - 点赞 / 回复 / 删除 / 新增 自行调用 gateway
+ *
+ * Events:
+ *   - complete  { type: 'add' | 'del', count: number }
+ *                父组件用来同步外层 likes/comments 计数
+ */
+import { stripHtml } from '@/features/feed/clip-normalize'
+import { buildOssUrl } from '@/core/media/oss-url'
+import {
+	addClipComment,
+	deleteClipComment,
+	fetchClipComments,
+	toggleClipCommentLike
+} from '@/features/feed/feed-gateway'
+import { openUserProfile } from '@/core/nav/navigation'
+
+export default {
+	name: 'CommentThread',
+	props: {
+		findId: { type: [Number, String], default: 0 },
+		list: { type: Array, default: () => [] },
+		count: { type: Number, default: 0 },
+		listHeight: { type: Number, default: 400 },
+		safeBottomInset: { type: Number, default: 0 },
+		tabbarHeight: { type: Number, default: 0 }
+	},
+	data() {
+		return {
+			rows: [],
+			form: { pid: null, content: '' },
+			keyboardHeight: 0,
+			safeBottom: 0,
+			inputRowPx: 0,
+			inputInnerPx: 0,
+			inputBarPadV: 0,
+			inputBottomFineTune: 0,
+			_kbHandler: null,
+			_kbClearTimer: null
+		}
+	},
+	computed: {
+		signedIn() {
+			return !!this.$store.state.user.isLogin
+		},
+		placeholder() {
+			return this.form.pid ? '回复评论…' : '说点什么~'
+		},
+		/** 照搬 HKLive comment-nvue.vue#inputBarStyle,键盘弹起时再下移 inputBottomFineTune */
+		inputBarStyle() {
+			const tabH = this.tabbarHeight || 0
+			const safeB = this.safeBottom || 0
+			const kb = this.keyboardHeight > 0 ? this.keyboardHeight : 0
+			const fine = this.inputBottomFineTune || 0
+			const inner = this.inputInnerPx || uni.upx2px(70)
+			const padV = this.inputBarPadV || uni.upx2px(16)
+			const padH = uni.upx2px(24)
+			const barH = inner + padV * 2
+			if (kb > 0) {
+				const lift = Math.max(0, kb - tabH - fine)
+				return {
+					height: `${barH}px`,
+					paddingTop: `${padV}px`,
+					paddingBottom: `${padV}px`,
+					paddingLeft: `${padH}px`,
+					paddingRight: `${padH}px`,
+					bottom: `${lift}px`
+				}
+			}
+			return {
+				height: `${barH + safeB}px`,
+				paddingTop: `${padV}px`,
+				paddingBottom: `${safeB + padV}px`,
+				paddingLeft: `${padH}px`,
+				paddingRight: `${padH}px`,
+				bottom: `${tabH}px`
+			}
+		},
+		spacerHeight() {
+			if (this.keyboardHeight > 0) return 0
+			const inner = this.inputInnerPx || uni.upx2px(70)
+			const padV = this.inputBarPadV || uni.upx2px(16)
+			return inner + padV * 2 + (this.safeBottom || 0) + (this.tabbarHeight || 0) + 8
+		}
+	},
+	watch: {
+		list: {
+			immediate: true,
+			handler(next) {
+				this.rows = Array.isArray(next) ? next.slice() : []
+			}
+		}
+	},
+	mounted() {
+		const sys = uni.getSystemInfoSync() || {}
+		const sysSafe = (sys.safeAreaInsets && sys.safeAreaInsets.bottom) || 0
+		this.safeBottom = (this.safeBottomInset && this.safeBottomInset > 0) ? this.safeBottomInset : sysSafe
+		this.inputInnerPx = uni.upx2px(70)
+		this.inputBarPadV = uni.upx2px(16)
+		this.inputRowPx = this.inputInnerPx + this.inputBarPadV * 2
+		this.inputBottomFineTune = uni.upx2px(60)
+		if (uni.onKeyboardHeightChange) {
+			this._kbHandler = (res) => {
+				this.keyboardHeight = (res && res.height) || 0
+			}
+			uni.onKeyboardHeightChange(this._kbHandler)
+		}
+	},
+	beforeDestroy() {
+		if (this._kbClearTimer) clearTimeout(this._kbClearTimer)
+		if (this._kbHandler && uni.offKeyboardHeightChange) {
+			uni.offKeyboardHeightChange(this._kbHandler)
+			this._kbHandler = null
+		}
+	},
+	methods: {
+		plain(content) {
+			return stripHtml(content || '')
+		},
+		avatarSrc(path) {
+			return buildOssUrl(path, 80, 80, 2, 'avatar')
+		},
+		requireSignIn() {
+			if (this.signedIn) return true
+			uni.showModal({
+				title: '请先登录',
+				content: '登录后才能评论、点赞',
+				confirmText: '去登录',
+				cancelText: '取消',
+				success: ({ confirm }) => {
+					if (confirm) uni.navigateTo({ url: '/pages/passport/lobby' })
+				}
+			})
+			return false
+		},
+		onUser(row) {
+			openUserProfile(row)
+		},
+
+		/**
+		 * 评论 / 子评论点赞 toggle
+		 * @param {number} index 父评论索引
+		 * @param {number|null} childKey 子评论索引;null 表示对父评论操作
+		 */
+		async onLike(index, childKey = null) {
+			if (!this.requireSignIn()) return
+			const parent = this.rows[index]
+			if (!parent) return
+			const target = childKey == null ? parent : (parent.childlist && parent.childlist[childKey])
+			if (!target || !target.id) return
+			if (target._busy) return
+			target._busy = true
+
+			const was = !!target.hasLike
+			const next = !was
+			this.$set(target, 'hasLike', next)
+			this.$set(target, 'like', Math.max(0, Number(target.like || 0) + (next ? 1 : -1)))
+
+			try {
+				const flag = await toggleClipCommentLike(target.id)
+				if (flag === 1 || flag === 0) {
+					const serverOn = flag === 1
+					if (serverOn !== next) {
+						this.$set(target, 'hasLike', serverOn)
+						this.$set(target, 'like', Math.max(0, Number(target.like || 0) + (serverOn ? 0 : -2) /* 抵消乐观更新 */))
+					}
+				}
+			} catch (err) {
+				console.warn('[comment] like failed, rolling back', err)
+				this.$set(target, 'hasLike', was)
+				this.$set(target, 'like', Math.max(0, Number(target.like || 0) + (was ? 1 : -1)))
+				uni.showToast({ title: (err && err.message) || '点赞失败', icon: 'none' })
+			} finally {
+				target._busy = false
+			}
+		},
+
+		/**
+		 * 点 "回复" 时:把输入框预填 "@昵称 "、设置 pid 为父评论 id、聚焦
+		 */
+		onReply(_pUserNick, reUserNick, pid) {
+			if (!this.requireSignIn()) return
+			this.form.pid = pid
+			this.form.content = reUserNick ? `@${reUserNick} ` : ''
+			this.focusInput()
+		},
+
+		focusInput() {
+			this.$nextTick(() => {
+				try {
+					this.$refs.commentInput && this.$refs.commentInput.focus && this.$refs.commentInput.focus()
+				} catch (_) {}
+			})
+		},
+
+		clearForm() {
+			this.form.pid = null
+			this.form.content = ''
+		},
+		async reloadComments() {
+			if (!this.findId) return this.count || 0
+			const result = await fetchClipComments(this.findId)
+			this.rows = Array.isArray(result.list) ? result.list.slice() : []
+			return Number(result.count) || this.rows.length || 0
+		},
+		/**
+		 * 提交评论 / 回复
+		 */
+		async onSubmit() {
+			if (!this.requireSignIn()) return
+			const content = (this.form.content || '').trim()
+			if (content.length < 2) {
+				uni.showToast({ title: '内容太短', icon: 'none' })
+				return
+			}
+			try {
+				await addClipComment(this.findId, content, this.form.pid)
+				uni.showToast({ title: '已发送', icon: 'none' })
+				this.clearForm()
+				const nextCount = await this.reloadComments()
+				this.$emit('complete', { type: 'add', count: nextCount, list: this.rows.slice() })
+			} catch (err) {
+				console.warn('[comment] add failed', err)
+				uni.showToast({ title: (err && err.message) || '发送失败', icon: 'none' })
+			}
+		},
+
+		/**
+		 * 删除评论(含确认弹框)
+		 * @param {number} index 父评论索引
+		 * @param {number|null} childKey 子评论索引;null 表示删除父评论
+		 */
+		async onDelete(index, childKey = null) {
+			if (!this.requireSignIn()) return
+			uni.showModal({
+				title: '删除评论',
+				content: '删除后无法恢复,将同时删除其下所有回复。',
+				confirmText: '确认',
+				cancelText: '取消',
+				success: async ({ confirm }) => {
+					if (!confirm) return
+					const parent = this.rows[index]
+					if (!parent) return
+					const target = childKey == null ? parent : (parent.childlist && parent.childlist[childKey])
+					if (!target || !target.id) return
+					try {
+						const result = await deleteClipComment(target.id, target.find_id || this.findId)
+						if (childKey == null) {
+							this.rows.splice(index, 1)
+						} else {
+							parent.childlist.splice(childKey, 1)
+						}
+						const nextCount = result && result.count != null ? result.count : Math.max(0, (this.count || 0) - 1)
+						this.$emit('complete', { type: 'del', count: nextCount })
+					} catch (err) {
+						console.warn('[comment] delete failed', err)
+						uni.showToast({ title: (err && err.message) || '删除失败', icon: 'none' })
+					}
+				}
+			})
+		},
+
+		onInputBlur() {
+			if (this._kbClearTimer) clearTimeout(this._kbClearTimer)
+			this._kbClearTimer = setTimeout(() => {
+				this.keyboardHeight = 0
+			}, 180)
+		}
+	}
+}
+</script>
+
+<style scoped>
+.ct-root {
+	background-color: transparent;
+}
+.ct-list {
+	padding: 0 28rpx;
+	box-sizing: border-box;
+}
+
+.ct-row {
+	display: flex;
+	flex-direction: row;
+	padding-top: 20rpx;
+	padding-bottom: 20rpx;
+}
+.ct-row-sub {
+	padding-top: 14rpx;
+	padding-bottom: 14rpx;
+}
+
+.ct-avatar {
+	width: 70rpx;
+	height: 70rpx;
+	border-radius: 50%;
+	background-color: rgba(255, 255, 255, 0.08);
+	margin-right: 20rpx;
+	flex-shrink: 0;
+}
+.ct-avatar-sub {
+	width: 60rpx;
+	height: 60rpx;
+	margin-right: 16rpx;
+}
+
+.ct-body {
+	flex: 1;
+	min-width: 0;
+}
+
+.ct-head {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+}
+.ct-head-left {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	flex: 1;
+	min-width: 0;
+}
+
+.ct-rank {
+	height: 32rpx;
+	padding: 0 10rpx;
+	border-radius: 10rpx;
+	margin-right: 12rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.ct-rank-1 { background-color: #ff8c1a; }
+.ct-rank-2 { background-color: #aaaa00; }
+.ct-rank-3 { background-color: #2b6cb0; }
+.ct-rank-text {
+	color: #ffffff;
+	font-size: 20rpx;
+	line-height: 28rpx;
+}
+
+.ct-nick {
+	color: rgba(255, 255, 255, 0.66);
+	font-size: 28rpx;
+	font-weight: 500;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	max-width: 350rpx;
+}
+.ct-nick-sub {
+	font-size: 26rpx;
+}
+
+.ct-like {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	padding-left: 16rpx;
+	padding-right: 0;
+	height: 50rpx;
+}
+.ct-like-num {
+	color: rgba(255, 255, 255, 0.66);
+	font-size: 24rpx;
+	margin-right: 8rpx;
+}
+.ct-like-num-on {
+	color: #ff4d6a;
+}
+.ct-like-icon {
+	color: rgba(255, 255, 255, 0.66);
+	font-size: 28rpx;
+}
+.ct-like-icon-on {
+	color: #ff4d6a;
+}
+
+.ct-content {
+	color: rgba(255, 255, 255, 0.9);
+	font-size: 28rpx;
+	line-height: 40rpx;
+	margin-top: 6rpx;
+	margin-bottom: 8rpx;
+}
+.ct-content-sub {
+	font-size: 26rpx;
+	line-height: 36rpx;
+}
+
+.ct-meta {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-bottom: 4rpx;
+}
+.ct-time {
+	color: rgba(255, 255, 255, 0.5);
+	font-size: 22rpx;
+	margin-right: 20rpx;
+}
+.ct-action {
+	margin-right: 20rpx;
+	padding: 4rpx 0;
+}
+.ct-action-reply {
+	color: #4a8cff;
+	font-size: 22rpx;
+}
+.ct-action-del {
+	color: #ff4d6a;
+	font-size: 22rpx;
+}
+
+.ct-sublist {
+	background-color: rgba(255, 255, 255, 0.04);
+	border-radius: 12rpx;
+	padding: 6rpx 16rpx;
+	margin-top: 12rpx;
+}
+
+.ct-empty {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+}
+.ct-empty-title {
+	color: rgba(255, 255, 255, 0.45);
+	font-size: 26rpx;
+}
+.ct-empty-cta {
+	color: #4a8cff;
+	font-size: 26rpx;
+	margin-top: 8rpx;
+	padding: 8rpx 16rpx;
+}
+
+.ct-inputbar {
+	position: fixed;
+	left: 0;
+	right: 0;
+	z-index: 100;
+	background-color: #1a1a1a;
+	border-top: 1rpx solid rgba(255, 255, 255, 0.08);
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	box-sizing: border-box;
+}
+.ct-input {
+	flex: 1;
+	height: 70rpx;
+	background-color: rgba(255, 255, 255, 0.08);
+	color: #ffffff;
+	border-radius: 35rpx;
+	padding: 0 28rpx;
+	font-size: 28rpx;
+}
+.ct-send {
+	height: 70rpx;
+	background-color: #4a8cff;
+	border-radius: 35rpx;
+	padding: 0 32rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	margin-left: 20rpx;
+	flex-shrink: 0;
+}
+.ct-send-text {
+	color: #ffffff;
+	font-size: 26rpx;
+	font-weight: 500;
+}
+</style>

+ 227 - 0
components/feed-reel/media-cell.vue

@@ -0,0 +1,227 @@
+<template>
+	<view class="cell" :style="frameStyle">
+		<video
+			v-if="src"
+			:id="playerId"
+			class="cell-video"
+			:style="frameStyle"
+			:src="src"
+			:controls="false"
+			:show-play-btn="false"
+			:enable-progress-gesture="false"
+			:object-fit="fitMode"
+			:http-cache="true"
+			:muted="muted"
+			:autoplay="playing"
+			:playsinline="true"
+			webkit-playsinline
+			:loop="true"
+			@play="onVideoPlay"
+			@pause="onVideoPause"
+			@loadedmetadata="onLoadedMetadata"
+			@canplay="onCanPlay"
+			@ended="onVideoEnded"
+			@error="onVideoError"
+		/>
+		<image
+			v-if="coverEnabled && showCover"
+			class="cell-cover"
+			:style="frameStyle"
+			:src="cover"
+			mode="aspectFill"
+		/>
+	</view>
+</template>
+
+<script>
+/**
+ * 短视频单元格(.vue 版)
+ *
+ * 注意:Vue2 不会把 data 里以 _/$ 开头的字段代理到 this 上,
+ * 定时器字段禁止用 _replayKickTimers 这种命名,否则 this.xxx 恒为 undefined。
+ */
+export default {
+	name: 'FeedMediaCell',
+	props: {
+		playerId: { type: String, default: 'feed-vid-0' },
+		src: { type: String, default: '' },
+		remoteSrc: { type: String, default: '' },
+		cover: { type: String, default: '' },
+		coverEnabled: { type: Boolean, default: true },
+		playing: { type: Boolean, default: false },
+		frameW: { type: Number, default: 375 },
+		frameH: { type: Number, default: 667 },
+		fitMode: { type: String, default: 'cover' }
+	},
+	data() {
+		return {
+			showCover: true,
+			booted: false,
+			muted: true,
+			playKickTimer: null,
+			replayKickTimers: [],
+			audioWakeTimers: []
+		}
+	},
+	computed: {
+		frameStyle() {
+			return {
+				width: `${this.frameW}px`,
+				height: `${this.frameH}px`
+			}
+		}
+	},
+	watch: {
+		playing(next, prev) {
+			this.syncPlayback(next)
+			if (next && !prev) this.scheduleAudioWake()
+			if (!next) this.clearAudioWakeTimers()
+		},
+		src(next, prev) {
+			if (next !== prev) {
+				this.showCover = true
+				this.booted = false
+				this.muted = true
+				this.clearPlayKickTimer()
+				this.clearReplayKickTimers()
+				this.clearAudioWakeTimers()
+			}
+		}
+	},
+	mounted() {
+		if (this.playing) this.ensurePlayback()
+	},
+	beforeDestroy() {
+		this.clearPlayKickTimer()
+		this.clearReplayKickTimers()
+		this.clearAudioWakeTimers()
+	},
+	methods: {
+		clearReplayKickTimers() {
+			const list = Array.isArray(this.replayKickTimers) ? this.replayKickTimers : []
+			list.forEach((id) => clearTimeout(id))
+			this.replayKickTimers = []
+		},
+		clearPlayKickTimer() {
+			if (!this.playKickTimer) return
+			clearTimeout(this.playKickTimer)
+			this.playKickTimer = null
+		},
+		clearAudioWakeTimers() {
+			const list = Array.isArray(this.audioWakeTimers) ? this.audioWakeTimers : []
+			list.forEach((id) => clearTimeout(id))
+			this.audioWakeTimers = []
+		},
+		videoCtx() {
+			return uni.createVideoContext(this.playerId, this)
+		},
+		requestPlay() {
+			if (!this.src || !this.playing) return
+			const ctx = this.videoCtx()
+			if (!ctx) return
+			try {
+				ctx.play()
+			} catch (_) {}
+		},
+		ensurePlayback() {
+			if (!this.playing || !this.src) return
+			this.muted = true
+			this.clearPlayKickTimer()
+			this.requestPlay()
+			this.playKickTimer = setTimeout(() => {
+				this.playKickTimer = null
+				if (this.playing) this.requestPlay()
+			}, 120)
+		},
+		scheduleAudioWake() {
+			this.clearAudioWakeTimers()
+			const timers = []
+			;[0, 90, 220, 480].forEach((delay) => {
+				timers.push(
+					setTimeout(() => {
+						if (!this.playing) return
+						this.requestPlay()
+					}, delay)
+				)
+			})
+			this.audioWakeTimers = timers
+		},
+		syncPlayback(shouldPlay) {
+			if (!this.src) return
+			if (shouldPlay) {
+				this.ensurePlayback()
+			} else {
+				this.muted = true
+				this.clearPlayKickTimer()
+				this.clearReplayKickTimers()
+				const ctx = this.videoCtx()
+				if (!ctx) return
+				try {
+					ctx.pause()
+				} catch (_) {}
+			}
+		},
+		onVideoPlay() {
+			this.showCover = false
+			this.booted = true
+			if (this.playing) this.muted = false
+		},
+		onVideoPause() {},
+		onLoadedMetadata() {
+			if (this.playing) this.ensurePlayback()
+		},
+		onCanPlay() {
+			if (this.playing) this.ensurePlayback()
+		},
+		onVideoEnded() {
+			if (!this.playing) return
+			this.replayFromStart()
+		},
+		replayFromStart() {
+			this.showCover = false
+			this.clearPlayKickTimer()
+			this.clearReplayKickTimers()
+			const ctx = this.videoCtx()
+			if (!ctx) return
+			const kick = () => {
+				if (!this.playing || !this.src) return
+				try {
+					ctx.seek(0)
+				} catch (_) {}
+				try {
+					ctx.play()
+				} catch (_) {}
+			}
+			kick()
+			const timers = []
+			;[40, 120, 280, 520].forEach((ms) => {
+				timers.push(setTimeout(() => kick(), ms))
+			})
+			this.replayKickTimers = timers
+		},
+		onVideoError() {
+			this.showCover = true
+			this.muted = true
+			if (this.remoteSrc && this.src !== this.remoteSrc) {
+				this.$emit('fallback', this.remoteSrc)
+			}
+		}
+	}
+}
+</script>
+
+<style scoped>
+.cell {
+	position: relative;
+	background-color: #000000;
+	overflow: hidden;
+}
+.cell-video {
+	background-color: #000000;
+}
+.cell-cover {
+	position: absolute;
+	left: 0;
+	top: 0;
+}
+</style>

+ 1362 - 0
components/feed-reel/vertical-reel.vue

@@ -0,0 +1,1362 @@
+<template>
+	<view class="reel-root" :style="rootStyle">
+		<view v-if="clips.length === 0 && phase === 'loading'" class="reel-loading" :style="rootStyle">
+			<view class="reel-loading-inner">
+				<view class="reel-loading-spinner" />
+				<text class="reel-loading-txt">{{ copy.loading }}</text>
+			</view>
+		</view>
+		<view v-else-if="clips.length === 0 && phase === 'empty'" class="reel-empty" :style="rootStyle">
+			<view class="reel-empty-inner">
+				<text class="reel-empty-icon">🎬</text>
+				<text class="reel-empty-txt">{{ copy.empty }}</text>
+			</view>
+		</view>
+
+		<swiper
+			v-if="clips.length > 0"
+			:key="'reel-sw-' + swiperEpoch"
+			class="reel-swiper"
+			vertical="true"
+			:acceleration="true"
+			:circular="swiperCircular"
+			:current="activeIndex"
+			:style="rootStyle"
+			@change="onSlideChange"
+		>
+			<swiper-item v-for="(clip, index) in clips" :key="clipKey(clip, index)">
+				<view v-if="shouldMount(index)" class="reel-slide" :style="rootStyle">
+					<feed-media-cell
+						:key="mediaCellKey(clip, index)"
+						:player-id="mediaPlayerId(clip, index)"
+						:src="playbackSrc(clip)"
+						:remote-src="mediaSrc(clip)"
+						:cover="mediaCover(clip)"
+						:cover-enabled="showCover"
+						:playing="isClipPlaying(clip)"
+						:frame-w="frameW"
+						:frame-h="frameH"
+						:fit-mode="(clip.video && clip.video.fit) || 'cover'"
+						@fallback="onPlaybackFallback(index, $event)"
+					/>
+
+					<!-- 主点击区:占满视频上方,底部 240px+safe 让位给用户卡 -->
+					<view
+						v-if="index === activeIndex"
+						class="reel-tap-layer"
+						:style="tapLayerStyle"
+						@click.stop="togglePause(index)"
+					/>
+
+					<!-- 暂停态:全屏居中图标 + 点击任意视频区域继续播放 -->
+					<view
+						v-if="index === activeIndex && !isClipPlaying(clip)"
+						class="reel-pause-overlay"
+						:style="pauseOverlayStyle"
+						@click.stop="togglePause(index)"
+					>
+						<view class="reel-pause-hint">
+							<image class="reel-pause-icon-img" src="/static/icon/play.png" mode="aspectFit" />
+						</view>
+					</view>
+
+					<!--
+						底部用户卡 —— 直接内联在 vertical-reel.vue 模板里(不再做独立 vue 子组件)。
+						原因:uni-app 把 .vue 子作为 webview subwindow 嵌入 nvue 父;如果 user-card 单独
+						再做一层 .vue 孙组件,会变成嵌套 subwindow,不能跟同级 video 叠加,点击穿透到 native video。
+						HKLive 工程 components/find/play.vue 的按钮也是这样直接内联到父 vue 模板里。
+					-->
+					<view
+						class="reel-bottom-overlay"
+						:class="{ 'reel-bottom-overlay--expanded': isCaptionExpanded(clip) }"
+						:style="bottomOverlayStyle"
+						@click.stop
+					>
+						<view class="reel-bottom-scrim reel-bottom-scrim-deep" />
+						<view class="reel-bottom-scrim reel-bottom-scrim-top" />
+						<view class="fuc" :style="userCardStyle(clip)">
+							<!-- 左:头像 + 关注 -->
+							<view class="fuc-left">
+								<view class="fuc-avatar-wrap" @click.stop="onAvatarTap(index)">
+									<image
+										v-if="userAvatar(clip)"
+										class="fuc-avatar"
+										:src="userAvatar(clip)"
+										mode="aspectFill"
+									/>
+									<view v-else class="fuc-avatar fuc-avatar-fallback">
+										<text class="fuc-avatar-initial">{{ avatarInitial(clip) }}</text>
+									</view>
+								</view>
+								<view
+									:class="!!clip.isFollow ? 'fuc-follow fuc-followed' : 'fuc-follow'"
+									@click.stop="onFollowTap(index)"
+								>
+									<text
+										:class="!!clip.isFollow ? 'fuc-followed-text' : 'fuc-follow-text'"
+									>{{ clip.isFollow ? copy.followed : copy.followPlus }}</text>
+								</view>
+							</view>
+
+							<!-- 右:信息 + 操作按钮 -->
+							<view class="fuc-body">
+								<view class="fuc-top">
+									<view class="fuc-nickname-wrap" @click.stop="onAvatarTap(index)">
+										<text class="fuc-nickname" :lines="1">@{{ (clip.user && clip.user.nickname) || copy.guest }}</text>
+									</view>
+									<view class="fuc-actions">
+										<view class="fuc-action" @click.stop="onLikeTap(index)">
+											<text
+												:class="isLiked(clip) ? 'fuc-action-icon fuc-action-liked' : 'fuc-action-icon'"
+											>{{ isLiked(clip) ? '♥' : '♡' }}</text>
+										<!-- 点赞 -->
+											<text
+												v-if="showBottomBarLikeCount && formatCount(clip.likes)"
+												class="fuc-action-num"
+											>{{ formatCount(clip.likes) }}</text>
+										</view>
+										<!-- <view class="fuc-action fuc-action-spaced" @click.stop="onPraiseTap(index)">
+											<text class="fuc-action-icon">👍</text>
+										</view> -->
+										<view class="fuc-action fuc-action-spaced" @click.stop="onCommentTap(index)">
+											<text class="fuc-action-icon">💬</text>
+											<text v-if="formatCount(clip.comments)" class="fuc-action-num">{{ formatCount(clip.comments) }}</text>
+										</view>
+										<view
+											v-if="!isSelfClip(clip)"
+											class="fuc-action fuc-action-spaced"
+											@click.stop="onReportTap(index)"
+										>
+											<text class="fuc-action-icon">🚩</text>
+										</view>
+									</view>
+								</view>
+								<view v-if="plainCaption(clip)" class="fuc-caption-wrap">
+									<view v-if="!isCaptionExpanded(clip)" class="fuc-caption-collapsed">
+										<text class="fuc-caption fuc-caption--clip" :lines="1">{{ collapsedCaption(clip) }}</text>
+										<text
+											v-if="showCaptionToggle(clip)"
+											class="fuc-caption-toggle"
+											@click.stop="toggleCaption(index)"
+										>{{ copy.captionExpand }}</text>
+									</view>
+									<view v-else class="fuc-caption-expanded">
+										<text class="fuc-caption">{{ plainCaption(clip) }}</text>
+										<text
+											v-if="showCaptionToggle(clip)"
+											class="fuc-caption-toggle"
+											@click.stop="toggleCaption(index)"
+										>{{ copy.captionCollapse }}</text>
+									</view>
+								</view>
+							</view>
+						</view>
+					</view>
+				</view>
+			</swiper-item>
+		</swiper>
+
+		<!--
+			评论弹层
+			⚠️ 关键:mask 只覆盖 sheet 顶部以上的区域(屏幕高度 - sheet 高度)
+			    避免 mask 和 sheet/inputbar 在屏幕底部重叠导致的 hit-test 误判,
+			    这是之前"点 input 就把弹层收起"的根因 ——
+			    部分 webview 在 native input 上 hit-test 会优先把事件给同区域的非 input 兄弟节点。
+		-->
+		<view
+			v-if="commentVisible"
+			class="reel-comment-mask"
+			:style="commentMaskStyle"
+			@click.stop="closeComment"
+		/>
+		<view
+			v-if="commentVisible"
+			class="reel-comment-sheet"
+			:style="commentSheetStyle"
+		>
+			<view class="reel-comment-header">
+				<text class="reel-comment-title">评论</text>
+				<view class="reel-comment-close" @click.stop="closeComment">
+					<text class="reel-comment-close-icon">×</text>
+				</view>
+			</view>
+			<comment-thread
+				:find-id="commentClipId"
+				:list="commentList"
+				:count="(clips[commentClipIndex] && clips[commentClipIndex].comments) || 0"
+				:list-height="commentListHeight"
+				:safe-bottom-inset="safeBottom"
+				:tabbar-height="0"
+				@complete="onCommentComplete"
+			/>
+		</view>
+	</view>
+</template>
+
+<script>
+import FeedMediaCell from '@/components/feed-reel/media-cell.vue'
+import CommentThread from '@/components/feed-comment/comment-thread.vue'
+import {
+	fetchVideoFeedPage,
+	toggleClipLike,
+	toggleClipFollow,
+	fetchClipComments
+} from '@/features/feed/feed-gateway'
+import { readFeedPage, writeFeedPage } from '@/features/feed/feed-list-cache'
+import {
+	resolvePlaybackUrl,
+	prefetchNeighbors,
+	readCachedPlaybackUrl,
+	dropCachedPlaybackUrl
+} from '@/features/feed/video-asset-cache'
+import { stripHtml } from '@/features/feed/clip-normalize'
+import { absolutizeMedia } from '@/features/feed/media-resolve'
+import { buildOssUrl } from '@/core/media/oss-url'
+import appSettings from '@/core/config/app-settings'
+import { STORAGE } from '@/core/constants/storage-keys'
+
+const PREFETCH_TAIL = 2
+
+/** nvue 内嵌子 webview 常无 vue-i18n,文案走本地表兜底 */
+const REEL_COPY = {
+	'zh-Hans': {
+		loading: '加载中…',
+		empty: '暂无视频',
+		needLogin: '请先登录',
+		captionExpand: '展开',
+		captionCollapse: '收起',
+		followPlus: '+ 关注',
+		followed: '已关注',
+		guest: '游客'
+	},
+	'zh-Hant': {
+		loading: '加載中…',
+		empty: '暫無視頻',
+		needLogin: '請先登錄',
+		captionExpand: '展開',
+		captionCollapse: '收起',
+		followPlus: '+ 關注',
+		followed: '已關注',
+		guest: '遊客'
+	},
+	en: {
+		loading: 'Loading…',
+		empty: 'No videos',
+		needLogin: 'Please sign in first',
+		captionExpand: 'Expand',
+		captionCollapse: 'Collapse',
+		followPlus: '+ Follow',
+		followed: 'Following',
+		guest: 'Guest'
+	}
+}
+
+function resolveReelLang() {
+	let code = ''
+	try {
+		code = uni.getStorageSync(STORAGE.language) || uni.getLocale() || ''
+	} catch (_) {
+		code = ''
+	}
+	if (!code || code === 'auto') {
+		try {
+			code = uni.getLocale() || 'zh-Hans'
+		} catch (_) {
+			code = 'zh-Hans'
+		}
+	}
+	if (REEL_COPY[code]) return code
+	if (code === 'zh-HK' || code === 'zh-TW' || String(code).indexOf('Hant') >= 0) return 'zh-Hant'
+	if (String(code).indexOf('zh') === 0) return 'zh-Hans'
+	if (String(code).indexOf('en') === 0) return 'en'
+	return 'zh-Hans'
+}
+
+export default {
+	name: 'FeedVerticalReel',
+	components: { FeedMediaCell, CommentThread },
+	props: {
+		frameW: { type: Number, default: 375 },
+		frameH: { type: Number, default: 667 },
+		safeBottom: { type: Number, default: 0 },
+		tabActive: { type: Boolean, default: true },
+		showCover: { type: Boolean, default: true },
+		autoBootstrap: { type: Boolean, default: true },
+		entryFindId: { type: [String, Number], default: '' },
+		entryLabelId: { type: [String, Number], default: '' },
+		/** 个人主页入口:works | likes | myfind */
+		entryListType: { type: [String, Number], default: '' },
+		entryUserNo: { type: [String, Number], default: '' },
+		entryPage: { type: [String, Number], default: 1 },
+		entryCompId: { type: [String, Number], default: '' },
+		entryOrderType: { type: [String, Number], default: '' },
+		entryIsHotDian: { type: [String, Number], default: '' },
+		/** 列表已全部加载后,滑到末尾可循环回第一条(用于 reel 详情页等有限列表) */
+		loopAtEnd: { type: Boolean, default: false }
+	},
+	data() {
+		return {
+			entryBootstrapped: false,
+			clips: [],
+			activeIndex: 0,
+			pageNo: 1,
+			pageEnd: 1,
+			listFilters: {},
+			phase: 'idle',
+			/** 入口打开后再解锁 circular,避免 swiper circular 导致前几条 video 黑屏 */
+			circularUnlocked: false,
+			/** 强制重建 swiper / 当前 video,规避 iOS 首条黑屏 */
+			swiperEpoch: 0,
+			mediaEpoch: 0,
+			// ─── 评论弹层 ───
+			commentVisible: false,
+			commentLoading: false,
+			commentList: [],
+			commentClipId: 0,
+			commentClipIndex: -1
+		}
+	},
+	computed: {
+		copy() {
+			// nvue 内嵌子 webview 常无 vue-i18n,禁止触碰 this.$t / $t.bind
+			return REEL_COPY[resolveReelLang()] || REEL_COPY['zh-Hans']
+		},
+		showBottomBarLikeCount() {
+			return !!appSettings.showBottomBarLikeCount
+		},
+		rootStyle() {
+			return {
+				width: `${this.frameW}px`,
+				height: `${this.frameH}px`
+			}
+		},
+		tapLayerStyle() {
+			const row = this.clips[this.activeIndex]
+			const paused = row && !this.isClipPlaying(row)
+			if (paused) {
+				return {
+					width: `${this.frameW}px`,
+					height: `${this.frameH}px`
+				}
+			}
+			const bottomReserve = 240 + (this.safeBottom || 0)
+			const h = Math.max(this.frameH - bottomReserve, this.frameH * 0.5)
+			return {
+				width: `${this.frameW}px`,
+				height: `${h}px`
+			}
+		},
+		pauseOverlayStyle() {
+			return {
+				width: `${this.frameW}px`,
+				height: `${this.frameH}px`
+			}
+		},
+		bottomOverlayStyle() {
+			return {
+				width: `${this.frameW}px`
+			}
+		},
+		signedIn() {
+			return !!this.$store.state.user.isLogin
+		},
+		/** 弹层固定高度:屏幕 70%(键盘弹起时不改 sheet 高度,只让 inputbar 自己跟随键盘) */
+		commentSheetDesiredHeight() {
+			return Math.round(this.frameH * 0.7)
+		},
+		commentSheetStyle() {
+			const h = this.commentSheetDesiredHeight
+			return {
+				width: `${this.frameW}px`,
+				height: `${h}px`,
+				bottom: '0px'
+			}
+		},
+		/**
+		 * 遮罩与 sheet 底部对齐:top=0 + bottom=sheet高度。
+		 * 不能用 height=frameH-sheetH —— hideTabBar 后视口变高,frameH 仍是旧值会留出 ~tabbar 空隙。
+		 */
+		commentMaskStyle() {
+			const sheetH = this.commentSheetDesiredHeight
+			return {
+				width: `${this.frameW}px`,
+				top: '0px',
+				bottom: `${sheetH}px`
+			}
+		},
+		/** 评论列表区高度 = sheet - header - inputbar(HKLive 用固定 px,不用 flex) */
+		commentListHeight() {
+			const headerPx = uni.upx2px(88)
+			const inputPx = uni.upx2px(70 + 16 * 2 + 12)
+			return Math.max(160, this.commentSheetDesiredHeight - headerPx - inputPx)
+		},
+		/** 无更多分页且至少 2 条时开启 swiper 首尾衔接(iOS + 入口首屏关闭,避免首条黑屏) */
+		swiperCircular() {
+			if (this.isIosApp) return false
+			return (
+				this.loopAtEnd &&
+				this.circularUnlocked &&
+				this.clips.length >= 2 &&
+				this.pageNo >= this.pageEnd &&
+				this.phase !== 'loading'
+			)
+		},
+		isIosApp() {
+			try {
+				const sys = uni.getSystemInfoSync()
+				return sys.platform === 'ios'
+			} catch (_) {
+				return false
+			}
+		}
+	},
+	watch: {
+		activeIndex(next, prev) {
+			if (this.clips.length - next - 1 <= PREFETCH_TAIL) {
+				this.appendPage()
+			}
+			if (prev !== next) {
+				if (typeof prev === 'number') this.collapseCaption(prev)
+				this.warmAround(next)
+				this.commitPlayState(next)
+			}
+		},
+		tabActive(visible) {
+			if (visible) this.commitPlayState(this.activeIndex)
+			else this.haltAll()
+		}
+	},
+	mounted() {
+		if (this.autoBootstrap) {
+			this.bootstrap()
+			return
+		}
+		this.tryEntryBootstrap()
+	},
+	methods: {
+		hasEntryQuery() {
+			const findId = this.entryFindId
+			const labelId = this.entryLabelId
+			const listType = this.entryListType
+			const userNo = this.entryUserNo
+			if (findId != null && findId !== '') return true
+			if (labelId != null && labelId !== '') return true
+			if (listType != null && listType !== '' && userNo != null && userNo !== '') return true
+			return false
+		},
+		tryEntryBootstrap() {
+			if (this.autoBootstrap || this.entryBootstrapped || !this.hasEntryQuery()) return
+			this.entryBootstrapped = true
+			this.openFromEntry({
+				findId: this.entryFindId,
+				labelId: this.entryLabelId,
+				listType: this.entryListType,
+				userNo: this.entryUserNo,
+				page: Number(this.entryPage) || 1,
+				compId: this.entryCompId,
+				orderType: this.entryOrderType,
+				isHotDian: this.entryIsHotDian
+			})
+		},
+		clipKey(clip, index) {
+			return clip.id || clip.video_id || `clip-${index}`
+		},
+		mediaPlayerId(clip, index) {
+			return `feed-vid-${this.clipKey(clip, index)}`
+		},
+		mediaCellKey(clip, index) {
+			const base = this.clipKey(clip, index)
+			if (index === this.activeIndex) return `${base}-on-${this.mediaEpoch}`
+			return `${base}-off`
+		},
+		scheduleEntryPlayback(idx) {
+			this.warmAround(idx)
+			this.commitPlayState(idx)
+			// iOS 首条 video 挂载更慢;且勿在此处开启 circular(会重建 swiper 导致首条黑屏)
+			const retryMs = this.isIosApp ? 450 : 220
+			setTimeout(() => {
+				if (this.activeIndex !== idx) return
+				this.mediaEpoch += 1
+				this.$nextTick(() => {
+					this.warmAround(idx)
+					this.commitPlayState(idx)
+				})
+			}, retryMs)
+		},
+		plainCaption(clip) {
+			let text = stripHtml(clip.content)
+			if (text.includes('\\n')) {
+				text = text.replace(/\\n/g, '\n')
+			}
+			return text
+		},
+		collapsedCaption(clip) {
+			const text = this.plainCaption(clip)
+			if (!text) return ''
+			return text
+				.replace(/\\n/g, ' ')
+				.replace(/\r?\n+/g, ' ')
+				.replace(/\s+/g, ' ')
+				.trim()
+		},
+		isCaptionExpanded(clip) {
+			return !!(clip && clip._captionExpanded)
+		},
+		captionLikelyOverflow(clip) {
+			const text = this.plainCaption(clip)
+			if (!text) return false
+			if (/\r?\n/.test(text) || /\\n/.test(text)) return true
+			const flat = this.collapsedCaption(clip)
+			const bodyPx = Math.max(120, this.frameW - uni.upx2px(132 + 48 + 24 + 72))
+			const charWidth = uni.upx2px(26) * 0.52
+			const charsPerLine = Math.max(8, Math.floor(bodyPx / charWidth))
+			return flat.length > charsPerLine
+		},
+		showCaptionToggle(clip) {
+			return this.captionLikelyOverflow(clip)
+		},
+		toggleCaption(index) {
+			const clip = this.clips[index]
+			if (!clip) return
+			this.$set(clip, '_captionExpanded', !clip._captionExpanded)
+		},
+		collapseCaption(index) {
+			const clip = this.clips[index]
+			if (clip && clip._captionExpanded) {
+				this.$set(clip, '_captionExpanded', false)
+			}
+		},
+		mediaSrc(clip) {
+			return absolutizeMedia(clip.video && clip.video.url)
+		},
+		playbackSrc(clip) {
+			if (clip._localSrc) return clip._localSrc
+			const remote = this.mediaSrc(clip)
+			const cached = readCachedPlaybackUrl(remote)
+			return cached || remote
+		},
+		mediaCover(clip) {
+			return absolutizeMedia(clip.video && clip.video.cover_url)
+		},
+		avatarSrc(path) {
+			return buildOssUrl(path, 80, 80, 2, 'avatar')
+		},
+		userAvatar(clip) {
+			const path = clip && clip.user && clip.user.avatar
+			return path ? this.avatarSrc(path) : ''
+		},
+		// 头像 fallback 首字母
+		avatarInitial(clip) {
+			const s = String((clip && clip.user && clip.user.nickname) || '').trim()
+			return s ? s.slice(0, 1) : '?'
+		},
+		// 数字格式化(10000+ → w,1000+ → k)
+		formatCount(n) {
+			const v = Number(n) || 0
+			if (v <= 0) return ''
+			if (v >= 10000) return `${(v / 10000).toFixed(1)}w`
+			if (v >= 1000) return `${(v / 1000).toFixed(1)}k`
+			return String(v)
+		},
+		/**
+		 * 是否已点赞
+		 * 后端 find/find/getList 接口的 isLike 字段:0 未点赞 / 1 已点赞
+		 */
+		isLiked(clip) {
+			return clip && (clip.isLike === 1 || clip.isLike === '1')
+		},
+		// user-card 容器样式(按 safeBottom 撑开底部安全区)
+		userCardStyle(_clip) {
+			return {
+				paddingBottom: `${(this.safeBottom || 0) + 16}px`
+			}
+		},
+		// 从 clip 上抽取 user_no(关注主播 / 跳主页都要用)
+		userNoOf(clip) {
+			if (!clip) return ''
+			if (clip.user_no) return clip.user_no
+			if (clip.user && clip.user.user_no) return clip.user.user_no
+			if (clip.shop && clip.shop.user_no) return clip.shop.user_no
+			return ''
+		},
+		isSelfClip(clip) {
+			if (!this.signedIn) return false
+			const u = this.$store && this.$store.state && this.$store.state.user
+			if (!u) return false
+			const mine = String(u.user_no || u.id || '').trim()
+			const theirs = String(this.userNoOf(clip) || '').trim()
+			return !!(mine && theirs && mine === theirs)
+		},
+		requireSignIn() {
+			console.log('[feed.reel] requireSignIn signedIn=', this.signedIn)
+			if (!this.signedIn) {
+				console.warn('[feed.reel] action blocked: user not signed in')
+				uni.showModal({
+					title: '请先登录',
+					content: '登录后才能关注、点赞、评论',
+					confirmText: '去登录',
+					cancelText: '取消',
+					success: ({ confirm }) => {
+						if (confirm) uni.navigateTo({ url: '/pages/passport/lobby' })
+					}
+				})
+				return false
+			}
+			return true
+		},
+		/**
+		 * 关注 / 取消关注主播 —— 参考 HKLive\components\find\play.vue#handleFollow
+		 * 与 onLikeTap 同策略:乐观翻转 + 防抖 + 异常回滚
+		 */
+		async onFollowTap(targetIndex) {
+			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
+			const row = this.clips[idx]
+			const userNo = this.userNoOf(row)
+			console.log('[feed.reel] onFollowTap idx=', idx, 'userNo=', userNo)
+			if (!row || !userNo) {
+				uni.showToast({ title: '主播信息缺失 (user_no 为空)', icon: 'none' })
+				return
+			}
+			if (!this.requireSignIn()) return
+			if (row._followBusy) {
+				console.log('[feed.reel] onFollowTap throttled (busy)')
+				return
+			}
+			row._followBusy = true
+
+			const wasFollowed = !!row.isFollow
+			const nextFollowed = !wasFollowed
+			this.$set(row, 'isFollow', nextFollowed ? 1 : 0)
+
+			try {
+				const flag = await toggleClipFollow(userNo)
+				console.log('[feed.reel] toggleClipFollow ←', flag, 'predicted=', nextFollowed)
+				// flag === 1 → 已关注,flag === 0 → 已取消,其它(null/undefined)→ 信任前端预测
+				let finalFollowed = nextFollowed
+				if (flag === 1 || flag === 0) {
+					const serverFollowed = flag === 1
+					if (serverFollowed !== nextFollowed) {
+						console.warn('[feed.reel] onFollowTap server mismatch, reconciling')
+						this.$set(row, 'isFollow', serverFollowed ? 1 : 0)
+					}
+					finalFollowed = serverFollowed
+				}
+				uni.showToast({
+					title: finalFollowed ? '已关注' : '已取消关注',
+					icon: 'none'
+				})
+			} catch (err) {
+				console.warn('[feed.reel] toggleClipFollow failed, rolling back', err)
+				this.$set(row, 'isFollow', wasFollowed ? 1 : 0)
+				uni.showToast({ title: (err && err.message) || '关注失败', icon: 'none' })
+			} finally {
+				row._followBusy = false
+			}
+		},
+		onAvatarTap(targetIndex) {
+			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
+			const userNo = this.userNoOf(this.clips[idx])
+			if (!userNo) {
+				uni.showToast({ title: '主播信息缺失', icon: 'none' })
+				return
+			}
+			uni.navigateTo({
+				url: `/pages/profile/show?id=${encodeURIComponent(String(userNo))}`
+			})
+		},
+		onPraiseTap(targetIndex) {
+			console.log('[feed.reel] onPraiseTap idx=', targetIndex)
+			uni.showToast({ title: '敬请期待', icon: 'none' })
+		},
+		shouldMount(index) {
+			return Math.abs(this.activeIndex - index) <= 1
+		},
+		isClipPlaying(clip) {
+			return !!(clip && clip.play && this.tabActive)
+		},
+		async bootstrap(filters = {}) {
+			this.phase = 'loading'
+			this.pageNo = 1
+			this.clips = []
+			this.listFilters = { ...(filters || {}) }
+			delete this.listFilters.page
+
+			const useCache = !this.listFilters || !Object.keys(this.listFilters).length
+			if (useCache) {
+				const cached = readFeedPage(1)
+				if (cached && cached.rows && cached.rows.length) {
+					this.applyPagePack(cached, { fromCache: true })
+					this.phase = 'ready'
+					this.$nextTick(() => {
+						this.warmAround(0)
+						this.commitPlayState(0)
+					})
+				}
+			}
+
+			try {
+				const pack = await fetchVideoFeedPage(1, this.listFilters)
+				if (useCache) writeFeedPage(1, pack)
+				this.applyPagePack(pack)
+				this.phase = this.clips.length ? 'ready' : 'empty'
+				this.$nextTick(() => {
+					this.warmAround(this.activeIndex)
+					this.commitPlayState(this.activeIndex)
+				})
+			} catch (err) {
+				if (!this.clips.length) {
+					this.phase = 'empty'
+					uni.showToast({ title: err.message || '加载失败', icon: 'none' })
+				}
+			}
+		},
+		/**
+		 * 从外部入口打开指定视频(find_id / label_id / type+user_no / page)
+		 */
+		async openFromEntry({
+			findId = '',
+			labelId = '',
+			listType = '',
+			userNo = '',
+			page = 1,
+			compId = '',
+			orderType = '',
+			isHotDian = ''
+		} = {}) {
+			const filters = {}
+			// 个人主页:用 type + user_no;标签流才用 label_id
+			if (listType != null && listType !== '') {
+				filters.type = listType
+				if (userNo != null && userNo !== '') filters.user_no = userNo
+			} else if (labelId != null && labelId !== '') {
+				filters.label_id = labelId
+			}
+			if (compId != null && compId !== '') filters.compID = compId
+			if (orderType != null && orderType !== '') filters.order_type = orderType
+			if (isHotDian != null && isHotDian !== '') filters.is_hot_dian = isHotDian
+			const startPage = Number(page) || 1
+
+			// find_id 仅用于首屏定位,不写入 listFilters,避免分页一直带 find_id
+			const requestFilters = { ...filters }
+			if (findId != null && findId !== '') requestFilters.find_id = findId
+
+			this.circularUnlocked = false
+			this.swiperEpoch += 1
+			this.mediaEpoch += 1
+			this.activeIndex = 0
+			this.phase = 'loading'
+			this.clips = []
+			this.listFilters = { ...filters }
+
+			try {
+				const pack = await fetchVideoFeedPage(startPage, requestFilters)
+				this.applyPagePack(pack)
+				this.phase = this.clips.length ? 'ready' : 'empty'
+
+				// 优先按点击的 findId 定位;不要让 first_play 抢先(本列表 first_play 可能不在第一条)
+				let idx = -1
+				if (findId != null && findId !== '') {
+					idx = this.clips.findIndex((row) => String(row.id) === String(findId))
+				}
+				if (idx < 0) {
+					idx = this.clips.findIndex(
+						(row) => row && row.first_play == 1 && this.mediaSrc(row)
+					)
+				}
+				if (idx < 0) {
+					idx = this.clips.findIndex((row) => row && row.first_play == 1)
+				}
+				if (idx < 0) idx = 0
+				this.activeIndex = idx
+
+				this.$nextTick(() => {
+					this.scheduleEntryPlayback(idx)
+				})
+			} catch (err) {
+				this.phase = 'empty'
+				uni.showToast({ title: err.message || '加载失败', icon: 'none' })
+			}
+		},
+		async openFromLabel(labelId) {
+			if (!labelId) return
+			this.activeIndex = 0
+			this.listFilters = { label_id: labelId }
+			await this.bootstrap({ label_id: labelId })
+			const idx = this.clips.findIndex((row) =>
+				String(row.id) === String(labelId) || String(row.label_id) === String(labelId)
+			)
+			if (idx > 0) {
+				this.activeIndex = idx
+				this.$nextTick(() => {
+					this.warmAround(idx)
+					this.commitPlayState(idx)
+				})
+			}
+		},
+		applyPagePack(pack, opts) {
+			const append = opts && opts.append
+			this.pageNo = pack.currentPage
+			this.pageEnd = pack.lastPage
+			const rows = (pack.rows || []).map((row) => Object.assign({}, row, {
+				_localSrc: '',
+				userPaused: false,
+				_captionExpanded: false
+			}))
+			this.clips = append ? this.clips.concat(rows) : rows
+		},
+		warmAround(index) {
+			prefetchNeighbors(this.clips, index, (clip) => this.mediaSrc(clip))
+			const center = this.clips[index]
+			const next = this.clips[index + 1]
+			const jobs = [center, next].filter(Boolean)
+			jobs.forEach((clip) => {
+				const remote = this.mediaSrc(clip)
+				if (!remote) return
+				const cached = readCachedPlaybackUrl(remote)
+				if (cached) {
+					clip._localSrc = cached
+					return
+				}
+				resolvePlaybackUrl(remote).then((local) => {
+					if (local && local !== remote) clip._localSrc = local
+				})
+			})
+		},
+		async appendPage() {
+			if (this.phase === 'loading' || this.pageNo >= this.pageEnd) return
+			this.phase = 'loading'
+			const next = this.pageNo + 1
+			try {
+				let pack = readFeedPage(next)
+				if (!pack) {
+					pack = await fetchVideoFeedPage(next, this.listFilters)
+					if (!Object.keys(this.listFilters).length) writeFeedPage(next, pack)
+				}
+				this.applyPagePack(pack, { append: true })
+				this.warmAround(this.activeIndex)
+				this.phase = 'ready'
+			} catch (_) {
+				this.phase = 'ready'
+			}
+		},
+		onSlideChange(e) {
+			const next = e.detail.current
+			if (next === this.activeIndex) return
+			this.circularUnlocked = true
+			this.haltAll()
+			// iOS 滑回第 0 条时强制重建 video,避免首条黑屏残留
+			if (this.isIosApp && next === 0) this.mediaEpoch += 1
+			this.activeIndex = next
+		},
+		haltAll() {
+			this.clips.forEach((row) => {
+				this.$set(row, 'play', false)
+			})
+		},
+		commitPlayState(index) {
+			if (!this.tabActive || !this.clips.length) return
+			this.haltAll()
+			const row = this.clips[index]
+			if (!row || !this.mediaSrc(row)) return
+			if (row.is_can_play === 0 || row.is_can_play === '0') return
+			this.$set(row, 'userPaused', false)
+			this.$set(row, 'play', true)
+		},
+		togglePause(index) {
+			if (index !== this.activeIndex) return
+			const row = this.clips[index]
+			if (!row) return
+			const next = !row.play
+			this.$set(row, 'play', next)
+			this.$set(row, 'userPaused', !next)
+		},
+		freezePlayback() {
+			this.haltAll()
+		},
+		wakePlayback() {
+			const row = this.clips[this.activeIndex]
+			if (row && row.userPaused) return
+			this.commitPlayState(this.activeIndex)
+		},
+		onPlaybackFallback(index, remote) {
+			const clip = this.clips[index]
+			if (!clip) return
+			clip._localSrc = ''
+			dropCachedPlaybackUrl(remote || this.mediaSrc(clip))
+		},
+		/**
+		 * 点 "💬" 评论按钮:
+		 *   1. 校验登录 / clipId
+		 *   2. 拉评论列表(带 count)
+		 *   3. 打开底部弹层 <CommentThread>
+		 *
+		 * 注:未登录用户也允许「看」评论,只在点赞 / 回复 / 删除时再要求登录。
+		 * 参考 HKLive components/find/play.vue#handleComment 行为一致。
+		 */
+		async onCommentTap(targetIndex) {
+			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
+			const row = this.clips[idx]
+			console.log('[feed.reel] onCommentTap idx=', idx, 'clipId=', row && row.id)
+			if (!row) return
+			if (!row.id) {
+				uni.showToast({ title: '视频信息缺失 (id 为空)', icon: 'none' })
+				return
+			}
+			this.commentClipId = row.id
+			this.commentClipIndex = idx
+			this.commentLoading = true
+			// 打开评论时隐藏底部 tabbar,避免键盘与输入框之间多出 tabbar 高度
+			this.$emit('tabbar-change', false)
+			this.commentVisible = true
+			try {
+				const result = await fetchClipComments(row.id)
+				console.log('[feed.reel] fetchClipComments ←', result)
+				const count = result && result.count != null ? result.count : 0
+				this.$set(row, 'comments', count)
+				this.commentList = Array.isArray(result.list) ? result.list : []
+			} catch (err) {
+				console.warn('[feed.reel] fetchClipComments failed', err)
+				this.commentList = []
+				uni.showToast({ title: (err && err.message) || '评论加载失败', icon: 'none' })
+			} finally {
+				this.commentLoading = false
+			}
+		},
+		onReportTap(targetIndex) {
+			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
+			const row = this.clips[idx]
+			if (!row) return
+			if (this.$appKit && typeof this.$appKit.requireLogin === 'function') {
+				if (!this.$appKit.requireLogin()) return
+			} else if (!this.requireSignIn()) {
+				return
+			}
+			let url = '/pages/account/complaint'
+			const userNo = this.userNoOf(row)
+			if (userNo) {
+				url += `?type=0&id=${encodeURIComponent(String(userNo))}`
+			}
+			uni.navigateTo({ url })
+		},
+
+		closeComment() {
+			this.commentVisible = false
+			this.$emit('tabbar-change', true)
+			this.commentList = []
+			this.commentClipId = 0
+			this.commentClipIndex = -1
+		},
+
+		/**
+		 * <CommentThread> 子组件回调:评论数发生变化(新增 / 删除)
+		 * 把变化同步回当前视频卡片,让外层 "💬 X" 数字实时刷新
+		 */
+		onCommentComplete(evt) {
+			if (!evt || this.commentClipIndex < 0) return
+			const row = this.clips[this.commentClipIndex]
+			if (row) this.$set(row, 'comments', Number(evt.count) || 0)
+		},
+		/**
+		 * 点赞 / 取消点赞
+		 *
+		 * 策略:乐观翻转 + 防抖 + 异常回滚
+		 *   1. 立刻按当前 isLike 反转更新 UI,给用户即时反馈
+		 *   2. _likeBusy 防止短时间内连续点击触发多次请求
+		 *   3. 等服务端响应后只在结果跟前端预测不一致时矫正
+		 *   4. 接口异常时回滚到点击前的状态
+		 *
+		 * 不能像参考 play.vue 那样直接用服务端返回值 +/- likes —— 那只在
+		 * 服务端正确返回 toggle 后状态时才对,本工程后端遇到过始终返回 1
+		 * 的情况,会导致连点 N 次 likes 加 N。
+		 */
+		async onLikeTap(targetIndex) {
+			const idx = typeof targetIndex === 'number' ? targetIndex : this.activeIndex
+			const row = this.clips[idx]
+			console.log('[feed.reel] onLikeTap idx=', idx, 'clipId=', row && row.id)
+			if (!row) return
+			if (!row.id) {
+				uni.showToast({ title: '视频信息缺失 (id 为空)', icon: 'none' })
+				return
+			}
+			if (!this.requireSignIn()) return
+			if (row._likeBusy) {
+				console.log('[feed.reel] onLikeTap throttled (busy)')
+				return
+			}
+			row._likeBusy = true
+
+			// 乐观翻转
+			const wasLiked = this.isLiked(row)
+			const nextLiked = !wasLiked
+			const wasCount = Number(row.likes || 0)
+			this.$set(row, 'isLike', nextLiked ? 1 : 0)
+			this.$set(row, 'likes', Math.max(0, wasCount + (nextLiked ? 1 : -1)))
+
+			try {
+				const flag = await toggleClipLike(row.id)
+				console.log('[feed.reel] toggleClipLike ←', flag, 'predicted=', nextLiked)
+				// setFindUser type=likes 返回值:
+				//   flag === 1  →  这次操作是 "点赞" → 新状态:已点赞 → isLike = 1
+				//   flag === 0  →  这次操作是 "取消" → 新状态:未点赞 → isLike = 0
+				//   其它(null/undefined)→ 无法判定,信任前端乐观预测
+				if (flag === 1 || flag === 0) {
+					const serverLiked = flag === 1
+					if (serverLiked !== nextLiked) {
+						console.warn('[feed.reel] onLikeTap server mismatch, reconciling')
+						this.$set(row, 'isLike', serverLiked ? 1 : 0)
+						this.$set(row, 'likes', Math.max(0, wasCount + (serverLiked ? 1 : -1)))
+					}
+				}
+			} catch (err) {
+				console.warn('[feed.reel] toggleClipLike failed, rolling back', err)
+				this.$set(row, 'isLike', wasLiked ? 1 : 0)
+				this.$set(row, 'likes', wasCount)
+				uni.showToast({ title: (err && err.message) || '点赞失败', icon: 'none' })
+			} finally {
+				row._likeBusy = false
+			}
+		}
+	}
+}
+</script>
+
+<style scoped>
+.reel-root {
+	position: relative;
+	background-color: #000000;
+	overflow: hidden;
+}
+.reel-swiper {
+	width: 100%;
+	height: 100%;
+}
+.reel-slide {
+	position: relative;
+	background-color: #000000;
+	width: 100%;
+	height: 100%;
+}
+.reel-tap-layer {
+	position: absolute;
+	left: 0;
+	top: 0;
+	z-index: 5;
+	background-color: rgba(0, 0, 0, 0.001);
+}
+.reel-pause-overlay {
+	position: absolute;
+	left: 0;
+	top: 0;
+	z-index: 6;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+}
+.reel-pause-hint {
+	width: 120px;
+	height: 120px;
+	border-radius: 60px;
+	background-color: transparent;
+	/* background-color: rgba(0, 0, 0, 0.35); */
+	display: flex;
+	justify-content: center;
+	align-items: center;
+}
+.reel-pause-icon-img {
+	width: 48px;
+	height: 48px;
+}
+.reel-bottom-overlay {
+	position: absolute;
+	left: 0;
+	bottom: 0;
+	z-index: 8;
+	padding-top: 44rpx;
+}
+/* 两层 rgba 模拟自下而上渐变,高度随介绍文字展开;top 留出头像上探区域 */
+.reel-bottom-scrim {
+	position: absolute;
+	left: 0;
+	right: 0;
+	z-index: 0;
+}
+.reel-bottom-scrim-deep {
+	top: 44rpx;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.58);
+}
+.reel-bottom-overlay--expanded .reel-bottom-scrim-deep {
+	background-color: rgba(0, 0, 0, 0.78);
+}
+.reel-bottom-scrim-top {
+	top: 44rpx;
+	height: 80rpx;
+	background-color: rgba(0, 0, 0, 0);
+}
+.reel-bottom-overlay--expanded .reel-bottom-scrim-top {
+	height: 45%;
+	background-color: rgba(0, 0, 0, 0.42);
+}
+.reel-empty,
+.reel-loading {
+	position: absolute;
+	left: 0;
+	top: 0;
+	background-color: #000000;
+	display: flex;
+	flex-direction: column;
+	justify-content: center;
+	align-items: center;
+	z-index: 20;
+}
+.reel-loading-inner,
+.reel-empty-inner {
+	display: flex;
+	flex-direction: column;
+	justify-content: center;
+	align-items: center;
+	padding: 40rpx 48rpx;
+	border-radius: 24rpx;
+	background-color: rgba(255, 255, 255, 0.08);
+}
+.reel-loading-spinner {
+	width: 56px;
+	height: 56px;
+	border-radius: 28px;
+	border-width: 4px;
+	border-style: solid;
+	border-color: rgba(255, 255, 255, 0.18);
+	border-top-color: #4a8cff;
+	margin-bottom: 20px;
+}
+/* #ifndef APP-NVUE */
+.reel-loading-spinner {
+	animation: reel-spin 0.85s linear infinite;
+}
+@keyframes reel-spin {
+	from {
+		transform: rotate(0deg);
+	}
+	to {
+		transform: rotate(360deg);
+	}
+}
+/* #endif */
+.reel-empty-icon {
+	font-size: 44px;
+	margin-bottom: 16px;
+	opacity: 0.85;
+}
+.reel-empty-txt,
+.reel-loading-txt {
+	color: rgba(255, 255, 255, 0.88);
+	font-size: 28rpx;
+	line-height: 40rpx;
+	text-align: center;
+}
+
+/* ===== user-card 内联样式(原 feed-user-card.vue) ===== */
+.fuc {
+	position: relative;
+	z-index: 2;
+	display: flex;
+	flex-direction: row;
+	align-items: flex-start;
+	padding: 8rpx 24rpx 0;
+}
+.fuc-left {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	margin-right: 20rpx;
+	margin-top: -44rpx;
+	width: 132rpx;
+	position: relative;
+	z-index: 3;
+}
+.fuc-avatar-wrap {
+	width: 153rpx;
+	height: 153rpx;
+	border-radius: 50%;
+	border: 5rpx solid #ffffff;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	overflow: hidden;
+}
+.fuc-avatar {
+	width: 143rpx;
+	height: 143rpx;
+	border-radius: 50%;
+}
+.fuc-avatar-fallback {
+	background-color: #4a8cff;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+}
+.fuc-avatar-initial {
+	color: #ffffff;
+	font-size: 42rpx;
+	font-weight: bold;
+}
+.fuc-follow {
+	margin-top: -18rpx;
+	background-color: #ff4d6a;
+	border-radius: 22rpx;
+	height: 40rpx;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	padding: 0 16rpx;
+}
+.fuc-followed {
+	background-color: rgba(255, 255, 255, 0.18);
+	border: 1rpx solid rgba(255, 255, 255, 0.5);
+}
+.fuc-follow-text {
+	color: #ffffff;
+	font-size: 22rpx;
+	font-weight: bold;
+	line-height: 32rpx;
+}
+.fuc-followed-text {
+	color: rgba(255, 255, 255, 0.88);
+	font-size: 22rpx;
+	line-height: 32rpx;
+}
+
+.fuc-body {
+	flex: 1;
+	display: flex;
+	flex-direction: column;
+	padding-top: 16rpx;
+}
+.fuc-top {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: nowrap;
+	align-items: center;
+	justify-content: space-between;
+	padding-bottom: 8rpx;
+}
+.fuc-nickname-wrap {
+	flex: 1;
+	overflow: hidden;
+	margin-right: 12rpx;
+}
+.fuc-nickname {
+	color: #ffffff;
+	font-size: 30rpx;
+	font-weight: bold;
+	line-height: 42rpx;
+}
+.fuc-actions {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+}
+.fuc-action {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: center;
+	height: 72rpx;
+	padding: 0 8rpx;
+}
+.fuc-action-spaced {
+	margin-left: 0;
+}
+.fuc-action-icon {
+	color: rgba(255, 255, 255, 0.92);
+	font-size: 44rpx;
+	line-height: 56rpx;
+}
+.fuc-action-liked {
+	color: #ff4d6a;
+}
+.fuc-action-num {
+	color: rgba(255, 255, 255, 0.85);
+	font-size: 22rpx;
+	margin-left: 6rpx;
+}
+.fuc-caption-wrap {
+	margin-top: 4rpx;
+}
+.fuc-caption-collapsed {
+	display: flex;
+	flex-direction: row;
+	align-items: flex-end;
+	overflow: hidden;
+}
+.fuc-caption-expanded {
+	display: flex;
+	flex-direction: column;
+}
+.fuc-caption {
+	color: rgba(255, 255, 255, 0.92);
+	font-size: 26rpx;
+	line-height: 38rpx;
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+.fuc-caption--clip {
+	flex: 1;
+	height: 38rpx;
+	overflow: hidden;
+}
+.fuc-caption-toggle {
+	color: rgba(255, 255, 255, 0.65);
+	font-size: 26rpx;
+	line-height: 38rpx;
+	padding-left: 8rpx;
+}
+.fuc-caption-expanded .fuc-caption-toggle {
+	padding-left: 0;
+	padding-top: 4rpx;
+}
+
+/* ─── 评论弹层 ─── */
+.reel-comment-mask {
+	position: fixed;
+	left: 0;
+	top: 0;
+	z-index: 50;
+	background-color: rgba(0, 0, 0, 0.4);
+}
+.reel-comment-sheet {
+	position: fixed;
+	left: 0;
+	bottom: 0;
+	z-index: 51;
+	background-color: rgba(10, 10, 10, 0.98);
+	border-top-left-radius: 24rpx;
+	border-top-right-radius: 24rpx;
+	overflow: hidden;
+}
+.reel-comment-header {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	padding: 24rpx 32rpx;
+	border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
+}
+.reel-comment-title {
+	color: #ffffff;
+	font-size: 30rpx;
+	font-weight: 600;
+}
+.reel-comment-close {
+	width: 60rpx;
+	height: 60rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.reel-comment-close-icon {
+	color: rgba(255, 255, 255, 0.7);
+	font-size: 48rpx;
+	line-height: 48rpx;
+}
+</style>

+ 118 - 0
components/find/play.vue

@@ -0,0 +1,118 @@
+<template>
+	<feed-vertical-reel
+		ref="reel"
+		:frame-w="width"
+		:frame-h="height"
+		:safe-bottom="safeBottom"
+		:tab-active="tabActive"
+		:auto-bootstrap="false"
+		:loop-at-end="true"
+		:entry-find-id="find_id"
+		:entry-label-id="label_id"
+		:entry-list-type="list_type"
+		:entry-user-no="user_no"
+		:entry-page="entryPageNum"
+		:entry-comp-id="compID"
+		:entry-order-type="order_type"
+		:entry-is-hot-dian="is_hot_dian"
+		@tabbar-change="$emit('tabbar-change', $event)"
+	/>
+</template>
+
+<script>
+import FeedVerticalReel from '@/components/feed-reel/vertical-reel.vue'
+
+/**
+ * 发现页竖滑播放器(对齐 88live components/find/play.vue 对外接口)
+ * nvue 页 video.nvue 引入本组件;内部复用 feed-vertical-reel 实现播放/点赞/评论。
+ */
+export default {
+	name: 'FindPlay',
+	components: { FeedVerticalReel },
+	props: {
+		label_id: {
+			type: [String, Number],
+			default: ''
+		},
+		find_id: {
+			type: [String, Number],
+			default: ''
+		},
+		/** 个人主页入口:works | likes | myfind */
+		list_type: {
+			type: [String, Number],
+			default: ''
+		},
+		user_no: {
+			type: [String, Number],
+			default: ''
+		},
+		compID: {
+			type: [String, Number],
+			default: ''
+		},
+		order_type: {
+			type: [String, Number],
+			default: ''
+		},
+		is_hot_dian: {
+			type: [String, Number],
+			default: ''
+		},
+		isComment: {
+			type: [String, Boolean],
+			default: 'Y'
+		},
+		height: {
+			type: Number,
+			default: 0
+		},
+		width: {
+			type: Number,
+			default: 0
+		},
+		bottom: {
+			type: Number,
+			default: 0
+		},
+		safeBottom: {
+			type: Number,
+			default: 0
+		},
+		page: {
+			type: [String, Number],
+			default: 1
+		},
+		tabActive: {
+			type: Boolean,
+			default: true
+		}
+	},
+	computed: {
+		entryPageNum() {
+			const n = Number(this.page)
+			return Number.isFinite(n) && n > 0 ? n : 1
+		}
+	},
+	methods: {
+		handleVideoPlay() {
+			const reel = this.$refs.reel
+			if (reel && typeof reel.wakePlayback === 'function') {
+				reel.wakePlayback()
+			}
+		},
+		handleVideoPause() {
+			const reel = this.$refs.reel
+			if (reel && typeof reel.freezePlayback === 'function') {
+				reel.freezePlayback()
+			}
+		},
+		tryEntryBootstrap() {
+			const reel = this.$refs.reel
+			if (reel && typeof reel.tryEntryBootstrap === 'function') {
+				reel.tryEntryBootstrap()
+			}
+		}
+	}
+}
+</script>

+ 642 - 0
components/image-cutter/image-cutter.vue

@@ -0,0 +1,642 @@
+<template>
+<view v-show="url" class="hl88-cut-root">
+	<canvas :style="{width: target.width + 'px', height: target.height + 'px'}" canvas-id="target"></canvas>
+	<view class="hl88-cut-body">
+		<image v-if="url" class="hl88-cut-image" @load="imageLoad" :style="{left: image.left + 'px', top: image.top + 'px', width: image.width + 'px', height: image.height + 'px'}" :src="url"></image>
+		<view v-if="mask.show" class="hl88-cut-mask"></view>
+		<view @touchstart="touchStart($event, 'plank')" @touchmove="touchMove" @touchend="touchEnd" @touchcancel="touchCancel"  class="hl88-cut-plank">
+			<view class="hl88-cut-frame" @touchstart="touchStart($event, 'frame')" @touchstart.stop.prevent="touchHandle" :style="{left: frame.left + 'px', top: frame.top + 'px', width: frame.width + 'px', height: frame.height + 'px'}">
+				<canvas v-if="mask.show" class="hl88-cut-canvas" :style="{width: frame.width + 'px', height: frame.height + 'px'}" canvas-id="canvas"></canvas>
+				<view class="hl88-cut-rect"></view>
+				<view class="hl88-cut-line-one"></view>
+				<view class="hl88-cut-line-two"></view>
+				<view class="hl88-cut-line-three"></view>
+				<view class="hl88-cut-line-four"></view>
+				<view @touchstart="touchStart($event, 'left')" @touchstart.stop.prevent="touchHandle" class="hl88-cut-frame-left"></view>
+				<view @touchstart="touchStart($event, 'right')" @touchstart.stop.prevent="touchHandle" class="hl88-cut-frame-right"></view>
+				<view @touchstart="touchStart($event, 'top')" @touchstart.stop.prevent="touchHandle" class="hl88-cut-frame-top"></view>
+				<view @touchstart="touchStart($event, 'bottom')" @touchstart.stop.prevent="touchHandle" class="hl88-cut-frame-bottom"></view>
+				<view @touchstart="touchStart($event, 'left-top')" @touchstart.stop.prevent="touchHandle" class="hl88-cut-frame-left-top"></view>
+				<view @touchstart="touchStart($event, 'left-bottom')" @touchstart.stop.prevent="touchHandle" class="hl88-cut-frame-left-bottom"></view>
+				<view @touchstart="touchStart($event, 'right-top')" @touchstart.stop.prevent="touchHandle" class="hl88-cut-frame-right-top"></view>
+				<view @touchstart="touchStart($event, 'right-bottom')" @touchstart.stop.prevent="touchHandle" class="hl88-cut-frame-right-bottom"></view>
+			</view>
+		</view>
+	</view>
+	<view class="hl88-cut-toolbar">
+		<button @tap="oncancle" class="hl88-cut-btn-cancel">返回</button>
+		<button @tap="onok" class="hl88-cut-btn-ok">确定</button>
+	</view>
+</view>
+</template>
+
+<script>
+export default {
+	props: {
+		url: {
+			type: String,
+			default: ""
+		},
+		fixed: {
+			type: Boolean,
+			default: false
+		},
+		width: {
+			type: Number,
+			default: 200
+		},
+		height: {
+			type: Number,
+			default: 200
+		},
+		maxWidth: {
+			type: Number,
+			default: 1024
+		},
+		maxHeight: {
+			type: Number,
+			default: 1024
+		},
+		blob: {
+			type: Boolean,
+			default: true
+		}
+	},
+	data() {
+		return {
+			mask: {
+				show: false
+			},
+			frame: {
+				left: 50,
+				top: 50,
+				width: this.width,
+				height: this.height
+			},
+			image: {
+				left: 20,
+				top: 20,
+				width: 300,
+				height: 400
+			},
+			real: {
+				width: 100,
+				height: 100
+			},
+			target: {
+				width: this.width,
+				height: this.height
+			},
+			touches: [],
+			type: "",
+			start: {
+				frame: {
+					left: 0,
+					top: 0,
+					width: 0,
+					height: 0
+				},
+				image: {
+					left: 0,
+					top: 0,
+					width: 0,
+					height: 0
+				},
+			},
+			timeoutId: -1,
+			context: null
+		};
+	},
+	mounted() {
+		//#ifdef H5
+		this.$el.addEventListener("touchmove", (ev) => {
+			ev.preventDefault();
+		});
+		// #endif
+		this.context = uni.createCanvasContext("canvas", this);
+		this.targetContext = uni.createCanvasContext("target", this);
+	},
+	methods: {
+		imageLoad(ev) {
+			this.mask.show = true;
+			this.real.width = ev.detail.width;
+			this.real.height = ev.detail.height;
+			this.image.width = ev.detail.width;
+			this.image.height = ev.detail.height;
+			this.frame.width = this.width;
+			this.frame.height = this.height;
+			if (!this.fixed) {
+				this.frame.width = this.image.width;
+				this.frame.height = this.image.height;
+			}
+			var query = uni.createSelectorQuery().in(this);
+			query.select(".hl88-cut-body").boundingClientRect((data) => {
+				var bw = data.width;
+				var bh = data.height;
+				var fw = this.frame.width;
+				var fh = this.frame.height;
+				var tw = bw * 0.8;
+				var th = bh * 0.8;
+				var sx = tw / fw;
+				var sy = th / fh;
+				var scale = sx;
+				if (sx < sy) {
+					scale = sy;
+				}
+				tw = fw * scale;
+				th = fh * scale;
+				var tx = (bw - tw) / 2;
+				var ty = (bh - th) / 2;
+				this.frame.width = tw;
+				this.frame.height = th;
+				this.frame.left = tx;
+				this.frame.top = ty;
+				
+				var iw = this.image.width;
+				var ih = this.image.height;
+				sx = tw / iw;
+				sy = th / ih;
+				scale = sx;
+				if (sx < sy) {
+					scale = sy;
+				}
+				this.image.width = iw * scale;
+				this.image.height = ih * scale;
+				this.image.left = (bw - this.image.width) / 2;
+				this.image.top = (bh - this.image.height) / 2;
+				setTimeout(() => {
+					this.trimImage();
+				}, 100);
+			}).exec();
+		},
+		touchHandle() {},
+		touchStart(ev, type) {
+			this.stopTime();
+			this.mask.show = false;
+			if (this.touches.length == 0) {
+				this.type = type;
+				this.start.frame.left = this.frame.left;
+				this.start.frame.top = this.frame.top;
+				this.start.frame.width = this.frame.width;
+				this.start.frame.height = this.frame.height;
+				this.start.image.left = this.image.left;
+				this.start.image.top = this.image.top;
+				this.start.image.width = this.image.width;
+				this.start.image.height = this.image.height;
+			}
+			var touches = ev.changedTouches;
+			for(var i = 0; i < touches.length; i++) {
+				var touch = touches[i];
+				// this.touches[touch.identifier] = touch;
+				this.touches.push(touch);
+			}
+		},
+		touchMove(ev) {
+			this.stopTime();
+			ev.preventDefault();
+			var touches = ev.touches;
+			if (this.touches.length == 1) {
+				if (this.type == "plank" || this.type == "frame" || this.fixed) {
+					this.moveImage(this.touches[0], touches[0]);
+				} else {
+					this.scaleFrame(this.touches[0], touches[0], this.type);
+				}
+			} else if (this.touches.length == 2 && touches.length == 2) {
+				var ta = this.touches[0];
+				var tb = this.touches[1];
+				var tc = touches[0];
+				var td = touches[1];
+				if (ta.identifier != tc.identifier) {
+					var temp = tc;
+					tc = td;
+					td = temp;
+				}
+				this.scaleImage(ta, tb, tc, td);
+			}
+		},
+		touchEnd(ev) {
+			this.type = "";
+			this.touches = [];
+			this.startTime();
+		},
+		touchCancel(ev) {
+			this.type = "";
+			this.touches = [];
+			this.startTime();
+		},
+		startTime() {
+			this.stopTime();
+			this.timeoutId = setTimeout(() => {
+				this.trimImage();
+			}, 800);
+		},
+		stopTime() {
+			if (this.timeoutId >= 0) {
+				clearTimeout(this.timeoutId);
+				this.timeoutId = -1;
+			}
+		},
+		trimImage() {
+			this.mask.show = true;
+			var query = uni.createSelectorQuery().in(this);
+			query.select(".hl88-cut-body").boundingClientRect((data) => {
+				var bw = data.width;
+				var bh = data.height;
+				var fw = this.frame.width;
+				var fh = this.frame.height;
+				var tw = bw * 0.8;
+				var th = bh * 0.8;
+				var sx = tw / fw;
+				var sy = th / fh;
+				var scale = sx;
+				if (sx > sy) {
+					scale = sy;
+				}
+				tw = fw * scale;
+				th = fh * scale;
+				var tx = (bw - tw) / 2;
+				var ty = (bh - th) / 2;
+				var ax = tx - this.frame.left + (this.frame.left - this.image.left) * (1 - scale);
+				var ay = ty - this.frame.top + (this.frame.top - this.image.top) * (1 - scale);
+				this.frame.width = tw;
+				this.frame.height = th;
+				this.frame.left = tx;
+				this.frame.top = ty;
+				this.image.width *= scale;
+				this.image.height *= scale;
+				this.image.left += ax;
+				this.image.top += ay;
+			}).exec();
+			setTimeout(() => {
+				var scale = this.image.width / this.real.width;
+				var x = (this.frame.left - this.image.left) / scale;
+				var y = (this.frame.top - this.image.top) / scale;
+				var width = this.frame.width / scale;
+				var height = this.frame.height / scale;
+				this.context.drawImage(this.url, x, y, width, height, 0, 0, this.frame.width, this.frame.height);
+				this.context.draw(false);
+			}, 100);
+		},
+		moveImage(ta, tb) {
+			var ax = tb.clientX - ta.clientX;
+			var ay = tb.clientY - ta.clientY;
+			this.image.left = this.start.image.left + ax;
+			this.image.top = this.start.image.top + ay;
+			if (this.image.left > this.frame.left) {
+				this.image.left = this.frame.left;
+			}
+			if (this.image.top > this.frame.top) {
+				this.image.top = this.frame.top;
+			}
+			if (this.image.left + this.image.width < this.frame.left + this.frame.width) {
+				this.image.left = this.frame.left + this.frame.width - this.image.width; 
+			}
+			if (this.image.top + this.image.height < this.frame.top + this.frame.height) {
+				this.image.top = this.frame.top + this.frame.height - this.image.height; 
+			}
+		},
+		scaleImage(ta, tb, tc, td) {
+			var x1 = ta.clientX;
+			var y1 = ta.clientY;
+			var x2 = tb.clientX;
+			var y2 = tb.clientY;
+			var x3 = tc.clientX;
+			var y3 = tc.clientY;
+			var x4 = td.clientX;
+			var y4 = td.clientY;
+			var ol = Math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
+			var el = Math.sqrt((x3 - x4) * (x3 - x4) + (y3 - y4) * (y3 - y4));
+			var ocx = (x1 + x2) / 2;
+			var ocy = (y1 + y2) / 2;
+			var ecx = (x3 + x4) / 2;
+			var ecy = (y3 + y4) / 2;
+			var ax = ecx - ocx;
+			var ay = ecy - ocy;
+			var scale = el / ol;
+			if (this.start.image.width * scale < this.frame.width) {
+				scale = this.frame.width / this.start.image.width;
+			}
+			if (this.start.image.height * scale < this.frame.height) {
+				scale = this.frame.height / this.start.image.height;
+			}
+			if (this.start.image.width * scale < this.frame.width) {
+				scale = this.frame.width / this.start.image.width;
+			}
+			this.image.left = this.start.image.left + ax - (ocx - this.start.image.left) * (scale - 1);
+			this.image.top = this.start.image.top + ay - (ocy - this.start.image.top) * (scale - 1);
+			this.image.width = this.start.image.width * scale;
+			this.image.height = this.start.image.height * scale;
+			if (this.image.left > this.frame.left) {
+				this.image.left = this.frame.left;
+			}
+			if (this.image.top > this.frame.top) {
+				this.image.top = this.frame.top;
+			}
+			if (this.image.left + this.image.width < this.frame.left + this.frame.width) {
+				this.image.left = this.frame.left + this.frame.width - this.image.width; 
+			}
+			if (this.image.top + this.image.height < this.frame.top + this.frame.height) {
+				this.image.top = this.frame.top + this.frame.height - this.image.height; 
+			}
+			
+		},
+		scaleFrame(ta, tb, type) {
+			var ax = tb.clientX - ta.clientX;
+			var ay = tb.clientY - ta.clientY;
+			var x1 = this.start.frame.left;
+			var y1 = this.start.frame.top;
+			var x2 = this.start.frame.left + this.start.frame.width;
+			var y2 = this.start.frame.top + this.start.frame.height;
+			if (type == "left") {
+				x1 += ax;
+			} else if (type == "right") {
+				x2 += ax;
+			} else if (type == "top") {
+				y1 += ay;
+			} else if (type == "bottom") {
+				y2 += ay;
+			} else if (type == "left-top") {
+				x1 += ax;
+				y1 += ay;
+			} else if (type == "left-bottom") {
+				x1 += ax;
+				y2 += ay;
+			} else if (type == "right-top") {
+				x2 += ax;
+				y1 += ay;
+			} else if (type == "right-bottom") {
+				x2 += ax;
+				y2 += ay;
+			}
+			if (x1 < this.image.left) {
+				x1 = this.image.left;
+			}
+			if (y1 < this.image.top) {
+				y1 = this.image.top;
+			}
+			if (x2 > this.image.left + this.image.width) {
+				x2 = this.image.left + this.image.width;
+			}
+			if (y2 > this.image.top + this.image.height) {
+				y2 = this.image.top + this.image.height;
+			}
+			this.frame.left = x1;
+			this.frame.top = y1;
+			this.frame.width = x2 - x1;
+			this.frame.height = y2 - y1;
+		},
+		parseBlob(base64) {
+			var arr = base64.split(',');
+			var mime = arr[0].match(/:(.*?);/)[1];
+			var bstr = atob(arr[1]);
+			var n = bstr.length;
+			var u8arr = new Uint8Array(n);
+			for(var i = 0; i < n; i++) {
+				u8arr[i] = bstr.charCodeAt(i);
+			}
+			var url = URL || webkitURL;
+			return url.createObjectURL(new Blob([u8arr], {type: mime}));
+		},
+		onok() {
+			var scale = this.image.width / this.real.width;
+			var x = (this.frame.left - this.image.left) / scale;
+			var y = (this.frame.top - this.image.top) / scale;
+			var width = this.frame.width / scale;
+			var height = this.frame.height / scale;
+			var tw = width;
+			var th = height;
+			if (this.fixed) {
+				tw = this.width / 2;
+				th = this.height / 2;
+			} else {
+				if (tw > this.maxWidth / 2) {
+					var sc = this.maxWidth / 2 / tw;
+					tw = tw * sc;
+					th = th * sc;
+				}
+				if (th > this.maxHeight / 2) {
+					var sc = this.maxHeight / 2 / th;
+					th = th * sc;
+					tw = tw * sc;
+				}
+			}
+			this.target.width = tw;
+			this.target.height = th;
+			uni.showLoading({
+				title: "正在裁剪"
+			});
+			setTimeout(() => {
+				this.targetContext.drawImage(this.url, x, y, width, height, 0, 0, tw, th);
+				this.targetContext.draw(false, () => {
+					uni.canvasToTempFilePath({
+						canvasId: "target",
+						success: (res) => {
+							var path = res.tempFilePath;
+							// #ifdef H5
+							if (this.blob) {
+								path = this.parseBlob(path);
+							}
+							// #endif
+							this.$emit("ok", {
+								path: path
+							});
+						},
+						fail: (ev) => {
+							console.log(ev);
+						},
+						complete: () => {
+							uni.hideLoading();
+						}
+					}, this);
+				});
+			}, 100);
+		},
+		oncancle() {
+			this.$emit("cancel");
+		}
+	}
+}
+</script>
+
+<style scoped>
+.hl88-cut-root {
+	position: absolute;
+	width: 100%;
+	height: 100%;
+	top: 0;
+	bottom: 0;
+	z-index: 1000;
+}
+.hl88-cut-toolbar {
+	position: absolute;
+	width: 100%;
+	height: 100upx;
+	left: 0upx;
+	bottom: 0upx;
+	box-sizing: border-box;
+	border-bottom: 1px solid #C0C0C0;
+	background: #F8F8F8;
+}
+.hl88-cut-btn-cancel {
+	position: absolute;
+	left: 100upx;
+	top: 12upx;
+	font-size: 30upx;
+	line-height: 30upx;
+	padding: 20upx;
+	color: #333333;
+}
+.hl88-cut-btn-ok {
+	position: absolute;
+	right: 100upx;
+	top: 12upx;
+	font-size: 30upx;
+	line-height: 30upx;
+	padding: 20upx;
+	color: #333333;
+}
+.hl88-cut-body {
+	position: absolute;
+	left: 0upx;
+	right: 0upx;
+	top: 0upx;
+	bottom: 100upx;
+	background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC);
+	overflow: hidden;
+}
+uni-button:after{
+	border: 0;
+}
+.hl88-cut-mask {
+	position: absolute;
+	left: 0upx;
+	right: 0upx;
+	top: 0upx;
+	bottom: 0upx;
+	background: black;
+	opacity: 0.6;
+}
+.hl88-cut-plank {
+	position: absolute;
+	left: 0upx;
+	right: 0upx;
+	top: 0upx;
+	bottom: 0upx;
+}
+.hl88-cut-image {
+	position: absolute;
+}
+.hl88-cut-frame {
+	position: absolute;
+}
+.hl88-cut-canvas {
+	position: absolute;
+	display: block;
+	left: 0px;
+	top: 0px;
+}
+.hl88-cut-rect {
+	position: absolute;
+	left: -2px;
+	top: -2px;
+	width: 100%;
+	height: 100%;
+	border: 2px solid white;
+}
+.hl88-cut-line-one {
+	position: absolute;
+	width: 100%;
+	height: 1px;
+	background: white;
+	left: 0;
+	top: 33.3%;
+}
+.hl88-cut-line-two {
+	position: absolute;
+	width: 100%;
+	height: 1px;
+	background: white;
+	left: 0;
+	top: 66.7%;
+}
+.hl88-cut-line-three {
+	position: absolute;
+	width: 1px;
+	height: 100%;
+	background: white;
+	top: 0;
+	left: 33.3%;
+}
+.hl88-cut-line-four {
+	position: absolute;
+	width: 1px;
+	height: 100%;
+	background: white;
+	top: 0;
+	left: 66.7%;
+}
+.hl88-cut-frame-left {
+	position: absolute;
+	height: 100%;
+	width: 8px;
+	left: -4px;
+	top: 0;
+}
+.hl88-cut-frame-right {
+	position: absolute;
+	height: 100%;
+	width: 8px;
+	right: -4px;
+	top: 0;
+}
+.hl88-cut-frame-top {
+	position: absolute;
+	width: 100%;
+	height: 8px;
+	top: -4px;
+	left: 0;
+}
+.hl88-cut-frame-bottom {
+	position: absolute;
+	width: 100%;
+	height: 8px;
+	bottom: -4px;
+	left: 0;
+}
+.hl88-cut-frame-left-top {
+	position: absolute;
+	width: 20px;
+	height: 20px;
+	left: -6px;
+	top: -6px;
+	border-left: 4px solid red;
+	border-top: 4px solid red;
+}
+.hl88-cut-frame-left-bottom {
+	position: absolute;
+	width: 20px;
+	height: 20px;
+	left: -6px;
+	bottom: -6px;
+	border-left: 4px solid red;
+	border-bottom: 4px solid red;
+}
+.hl88-cut-frame-right-top {
+	position: absolute;
+	width: 20px;
+	height: 20px;
+	right: -6px;
+	top: -6px;
+	border-right: 4px solid red;
+	border-top: 4px solid red;
+}
+.hl88-cut-frame-right-bottom {
+	position: absolute;
+	width: 20px;
+	height: 20px;
+	right: -6px;
+	bottom: -6px;
+	border-right: 4px solid red;
+	border-bottom: 4px solid red;
+}
+</style>

+ 473 - 0
components/live-broadcast/broadcast-setup-sheet.vue

@@ -0,0 +1,473 @@
+<template>
+	<view v-if="visible" class="bc-setup-mask" @tap="onMaskTap">
+		<view class="bc-setup-sheet" @tap.stop>
+			<view class="bc-setup-hd">
+				<text class="bc-setup-title">{{ copy.title }}</text>
+				<view class="bc-setup-close" @tap="close">
+					<text class="bc-setup-close-txt">×</text>
+				</view>
+			</view>
+
+			<scroll-view scroll-y class="bc-setup-scroll" :show-scrollbar="false">
+				<view class="bc-setup-block">
+					<text class="bc-setup-label">{{ copy.cover }}</text>
+					<view class="bc-setup-cover" @tap="pickCover">
+						<image
+							v-if="coverPreview"
+							class="bc-setup-cover-img"
+							:src="coverPreview"
+							mode="aspectFill"
+						/>
+						<view v-else class="bc-setup-cover-empty">
+							<text class="bc-setup-cover-plus">+</text>
+							<text class="bc-setup-cover-hint">{{ copy.coverHint }}</text>
+						</view>
+					</view>
+				</view>
+
+				<view class="bc-setup-block">
+					<view class="bc-setup-label-row">
+						<text class="bc-setup-label">{{ copy.theme }}</text>
+						<text class="bc-setup-count">{{ themeLen }}/50</text>
+					</view>
+					<textarea
+						class="bc-setup-textarea"
+						v-model="form.content"
+						:placeholder="copy.themePlaceholder"
+						placeholder-class="bc-setup-placeholder"
+						maxlength="50"
+						:adjust-position="true"
+					/>
+				</view>
+
+				<view class="bc-setup-block">
+					<text class="bc-setup-label">{{ copy.payment }}</text>
+					<view class="bc-setup-seg">
+						<view
+							class="bc-setup-seg-item"
+							:class="{ 'bc-setup-seg-item--on': form.is_paid === 0 }"
+							@tap="setPaid(0)"
+						>
+							<text
+								class="bc-setup-seg-txt"
+								:class="{ 'bc-setup-seg-txt--on': form.is_paid === 0 }"
+							>{{ copy.free }}</text>
+						</view>
+						<view
+							class="bc-setup-seg-item"
+							:class="{ 'bc-setup-seg-item--on': form.is_paid === 1 }"
+							@tap="setPaid(1)"
+						>
+							<text
+								class="bc-setup-seg-txt"
+								:class="{ 'bc-setup-seg-txt--on': form.is_paid === 1 }"
+							>{{ copy.paid }}</text>
+						</view>
+					</view>
+					<view v-if="form.is_paid === 1" class="bc-setup-paid-fields">
+						<view class="bc-setup-field">
+							<text class="bc-setup-field-label">{{ copy.paidAmount }}</text>
+							<input
+								class="bc-setup-input"
+								type="digit"
+								v-model="form.paid_money"
+								:placeholder="copy.paidAmountPh"
+							/>
+						</view>
+						<view class="bc-setup-field">
+							<text class="bc-setup-field-label">{{ copy.freePreview }}</text>
+							<input
+								class="bc-setup-input"
+								type="number"
+								v-model="form.free_look_time"
+								:placeholder="copy.freePreviewPh"
+							/>
+						</view>
+					</view>
+				</view>
+			</scroll-view>
+
+			<view class="bc-setup-ft" :style="{ paddingBottom: safeBottom + 'px' }">
+				<view class="bc-setup-submit" @tap="submit">
+					<text class="bc-setup-submit-txt">{{ copy.start }}</text>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import appSettings from '@/core/config/app-settings'
+import { fetchUploadConfig, uploadImageFile } from '@/features/dynamics/publish-gateway'
+
+export default {
+	name: 'BroadcastSetupSheet',
+	props: {
+		visible: { type: Boolean, default: false },
+		safeBottom: { type: Number, default: 0 }
+	},
+	data() {
+		return {
+			form: {
+				image: '',
+				content: '',
+				is_paid: 0,
+				paid_money: '',
+				free_look_time: '',
+				goods_ids: []
+			},
+			coverPreview: '',
+			uploading: false
+		}
+	},
+	computed: {
+		copy() {
+			const t = this.$t.bind(this)
+			return {
+				title: t('live.broadcastSetupTitle'),
+				cover: t('live.broadcastCover'),
+				coverHint: t('live.broadcastCoverHint'),
+				theme: t('live.broadcastTheme'),
+				themePlaceholder: t('live.broadcastThemePlaceholder'),
+				payment: t('live.broadcastPayment'),
+				free: t('live.broadcastFree'),
+				paid: t('live.broadcastPaid'),
+				paidAmount: t('live.broadcastPaidAmount'),
+				paidAmountPh: t('live.broadcastPaidAmountPh'),
+				freePreview: t('live.broadcastFreePreview'),
+				freePreviewPh: t('live.broadcastFreePreviewPh'),
+				start: t('live.broadcastStart')
+			}
+		},
+		themeLen() {
+			return String(this.form.content || '').length
+		}
+	},
+	watch: {
+		visible(val) {
+			if (!val) return
+			const user = (this.$store && this.$store.state.user) || {}
+			if (!this.form.image && user.avatar) {
+				this.form.image = user.avatar
+				this.coverPreview = this.toPreview(user.avatar)
+			}
+		}
+	},
+	methods: {
+		defaultCover() {
+			const user = (this.$store && this.$store.state.user) || {}
+			const avatar = user.avatar || ''
+			if (!avatar) return ''
+			if (/^https?:\/\//.test(avatar)) return avatar
+			return `${appSettings.cdnurl || ''}${avatar.startsWith('/') ? avatar : `/${avatar}`}`
+		},
+		toPreview(url) {
+			if (!url) return ''
+			if (/^https?:\/\//.test(url)) return url
+			return `${appSettings.cdnurl || ''}${url.startsWith('/') ? url : `/${url}`}`
+		},
+		onMaskTap() {
+			this.close()
+		},
+		close() {
+			this.$emit('close')
+		},
+		setPaid(flag) {
+			this.form.is_paid = flag ? 1 : 0
+		},
+		pickCover() {
+			if (this.uploading) return
+			uni.chooseImage({
+				count: 1,
+				sourceType: ['album'],
+				sizeType: ['compressed'],
+				success: async (res) => {
+					const path = res.tempFilePaths && res.tempFilePaths[0]
+					if (!path) return
+					this.uploading = true
+					uni.showLoading({ title: this.$t('live.broadcastUploading'), mask: true })
+					try {
+						const cfg = await fetchUploadConfig()
+						const url = await uploadImageFile(path, cfg)
+						this.form.image = url
+						this.coverPreview = this.toPreview(url)
+					} catch (err) {
+						uni.showToast({
+							title: err?.message || this.$t('live.broadcastUploadFailed'),
+							icon: 'none'
+						})
+					} finally {
+						this.uploading = false
+						uni.hideLoading()
+					}
+				}
+			})
+		},
+		validate() {
+			if (!this.form.image) {
+				uni.showToast({ title: this.$t('live.broadcastCoverRequired'), icon: 'none' })
+				return false
+			}
+			if (!String(this.form.content || '').trim()) {
+				uni.showToast({ title: this.$t('live.broadcastThemeRequired'), icon: 'none' })
+				return false
+			}
+			if (this.form.is_paid === 1) {
+				const money = Number(this.form.paid_money)
+				if (!money || money <= 0) {
+					uni.showToast({ title: this.$t('live.broadcastPaidInvalid'), icon: 'none' })
+					return false
+				}
+				const sec = Number(this.form.free_look_time)
+				if (sec < 0 || Number.isNaN(sec)) {
+					uni.showToast({ title: this.$t('live.broadcastFreePreviewInvalid'), icon: 'none' })
+					return false
+				}
+			}
+			return true
+		},
+		submit() {
+			if (!this.validate()) return
+			const payload = {
+				image: this.form.image,
+				content: String(this.form.content).trim(),
+				is_paid: this.form.is_paid,
+				paid_money: this.form.is_paid === 1 ? Number(this.form.paid_money) || 0 : 0,
+				free_look_time: this.form.is_paid === 1 ? Number(this.form.free_look_time) || 0 : 0,
+				goods_ids: []
+			}
+			this.$emit('start', payload)
+			this.close()
+		},
+		reset() {
+			this.form = {
+				image: '',
+				content: '',
+				is_paid: 0,
+				paid_money: '',
+				free_look_time: '',
+				goods_ids: []
+			}
+			this.coverPreview = ''
+		}
+	}
+}
+</script>
+
+<style scoped>
+.bc-setup-mask {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	z-index: 500;
+	background-color: rgba(26, 29, 41, 0.45);
+	display: flex;
+	flex-direction: column;
+	justify-content: flex-end;
+}
+
+.bc-setup-sheet {
+	width: 100%;
+	max-height: 88vh;
+	background-color: #f4f6fb;
+	border-radius: 28rpx 28rpx 0 0;
+	overflow: hidden;
+	display: flex;
+	flex-direction: column;
+}
+
+.bc-setup-hd {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	padding: 28rpx 32rpx 16rpx;
+}
+
+.bc-setup-title {
+	font-size: 34rpx;
+	font-weight: 700;
+	color: #1a1d26;
+}
+
+.bc-setup-close {
+	width: 56rpx;
+	height: 56rpx;
+	border-radius: 28rpx;
+	background-color: #ffffff;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	box-shadow: 0 4rpx 16rpx rgba(74, 140, 255, 0.1);
+}
+
+.bc-setup-close-txt {
+	font-size: 40rpx;
+	line-height: 1;
+	color: #8a8d9e;
+}
+
+.bc-setup-scroll {
+	flex: 1;
+	max-height: 62vh;
+	padding: 0 32rpx;
+	box-sizing: border-box;
+}
+
+.bc-setup-block {
+	margin-bottom: 28rpx;
+}
+
+.bc-setup-label-row {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	margin-bottom: 16rpx;
+}
+
+.bc-setup-label {
+	font-size: 28rpx;
+	font-weight: 600;
+	color: #1a1d26;
+	margin-bottom: 16rpx;
+}
+
+.bc-setup-count {
+	font-size: 24rpx;
+	color: #8a8d9e;
+}
+
+.bc-setup-cover {
+	width: 100%;
+	height: 320rpx;
+	border-radius: 24rpx;
+	overflow: hidden;
+	background-color: #ffffff;
+	box-shadow: 0 8rpx 28rpx rgba(74, 140, 255, 0.08);
+}
+
+.bc-setup-cover-img {
+	width: 100%;
+	height: 100%;
+}
+
+.bc-setup-cover-empty {
+	width: 100%;
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+}
+
+.bc-setup-cover-plus {
+	font-size: 56rpx;
+	color: #4a8cff;
+	line-height: 1;
+	margin-bottom: 12rpx;
+}
+
+.bc-setup-cover-hint {
+	font-size: 24rpx;
+	color: #8a8d9e;
+}
+
+.bc-setup-textarea {
+	width: 100%;
+	min-height: 160rpx;
+	padding: 24rpx;
+	box-sizing: border-box;
+	border-radius: 20rpx;
+	background-color: #ffffff;
+	font-size: 28rpx;
+	color: #1a1d26;
+	line-height: 1.5;
+	box-shadow: 0 8rpx 28rpx rgba(74, 140, 255, 0.06);
+}
+
+.bc-setup-placeholder {
+	color: #b0b3c0;
+}
+
+.bc-setup-seg {
+	display: flex;
+	flex-direction: row;
+	padding: 6rpx;
+	border-radius: 16rpx;
+	background-color: #e8edf5;
+}
+
+.bc-setup-seg-item {
+	flex: 1;
+	padding: 18rpx 0;
+	border-radius: 12rpx;
+	align-items: center;
+	justify-content: center;
+	display: flex;
+}
+
+.bc-setup-seg-item--on {
+	background-color: #ffffff;
+	box-shadow: 0 4rpx 12rpx rgba(74, 140, 255, 0.12);
+}
+
+.bc-setup-seg-txt {
+	font-size: 28rpx;
+	color: #8a8d9e;
+}
+
+.bc-setup-seg-txt--on {
+	color: #4a8cff;
+	font-weight: 600;
+}
+
+.bc-setup-paid-fields {
+	margin-top: 20rpx;
+}
+
+.bc-setup-field {
+	margin-bottom: 16rpx;
+}
+
+.bc-setup-field-label {
+	display: block;
+	font-size: 24rpx;
+	color: #8a8d9e;
+	margin-bottom: 10rpx;
+}
+
+.bc-setup-input {
+	width: 100%;
+	height: 80rpx;
+	padding: 0 24rpx;
+	box-sizing: border-box;
+	border-radius: 16rpx;
+	background-color: #ffffff;
+	font-size: 28rpx;
+	color: #1a1d26;
+}
+
+.bc-setup-ft {
+	padding: 16rpx 32rpx 24rpx;
+	background-color: #f4f6fb;
+}
+
+.bc-setup-submit {
+	height: 96rpx;
+	border-radius: 48rpx;
+	background: linear-gradient(135deg, #4a8cff 0%, #6ea8ff 100%);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	box-shadow: 0 12rpx 32rpx rgba(74, 140, 255, 0.28);
+}
+
+.bc-setup-submit-txt {
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #ffffff;
+}
+</style>

+ 175 - 0
components/live-room/broadcast-overlay.vue

@@ -0,0 +1,175 @@
+<template>
+	<view class="broadcast-overlay">
+		<view class="broadcast-overlay-header" :style="{ paddingTop: statusBarHeight + 'px' }">
+			<wanl-live-header
+				:shopdata="shopdata"
+				:state="state"
+				:online="online"
+				:confirmBeforeBack="true"
+				:showFollowButton="false"
+				@back="$emit('back')"
+			/>
+		</view>
+		<view
+			v-if="state === 1"
+			class="broadcast-overlay-message"
+			:style="{ bottom: statusFootHeight + 60 + 'px' }"
+		>
+			<wanl-live-message ref="message" />
+		</view>
+		<view
+			v-if="state === 1"
+			class="broadcast-overlay-like"
+			:style="likeStyle"
+			@tap="onLikeTap"
+		>
+			<view class="broadcast-like-btn">
+				<image class="broadcast-like-icon" src="/static/icon/thumb-up.png" mode="aspectFit" />
+			</view>
+			<text v-if="like > 0" class="broadcast-like-num">{{ likeText }}</text>
+		</view>
+		<view class="broadcast-overlay-control" :style="{ bottom: statusFootHeight + 'px' }">
+			<wanl-live-control
+				:statusFootHeight="statusFootHeight"
+				:screenHeight="screenHeight"
+				:screenWidth="screenWidth"
+				:liveId="liveId"
+				:like="like"
+				:state="state"
+				@send="$emit('send', $event)"
+				@change="onControlChange"
+			/>
+		</view>
+	</view>
+</template>
+
+<script>
+import wanlLiveHeader from '@/components/live-room/header.vue'
+import wanlLiveMessage from '@/components/live-room/message.vue'
+import wanlLiveControl from '@/components/live-room/control.vue'
+
+export default {
+	name: 'LiveBroadcastOverlay',
+	components: {
+		wanlLiveHeader,
+		wanlLiveMessage,
+		wanlLiveControl
+	},
+	props: {
+		shopdata: { type: Object, default: () => ({}) },
+		state: { type: Number, default: 0 },
+		online: { type: Number, default: 0 },
+		like: { type: Number, default: 0 },
+		liveId: { type: [Number, String], default: 0 },
+		statusBarHeight: { type: Number, default: 0 },
+		statusFootHeight: { type: Number, default: 0 },
+		screenHeight: { type: Number, default: 0 },
+		screenWidth: { type: Number, default: 0 }
+	},
+	computed: {
+		likeStyle() {
+			const upx = typeof uni !== 'undefined' && uni.upx2px ? uni.upx2px.bind(uni) : (v) => v / 2
+			const barPx = upx(112)
+			const btnPx = upx(84)
+			return {
+				bottom: `${this.statusFootHeight + Math.max(0, (barPx - btnPx) / 2)}px`,
+				right: '24rpx'
+			}
+		},
+		likeText() {
+			const n = Number(this.like) || 0
+			if (n <= 0) return ''
+			return n > 9999 ? `${Math.floor((n - n % 1000) / 10000)}w` : String(n)
+		}
+	},
+	methods: {
+		onLikeTap() {
+			this.$emit('send', { type: 'like' })
+		},
+		onControlChange(method, data) {
+			this.$emit('change', method, data)
+		},
+		onMessage(msg) {
+			const ref = this.$refs.message
+			if (ref && typeof ref.onMessage === 'function') {
+				ref.onMessage(msg)
+			}
+		}
+	}
+}
+</script>
+
+<style>
+/* #ifndef APP-NVUE */
+page {
+	background-color: transparent !important;
+}
+/* #endif */
+
+.broadcast-overlay {
+	position: fixed;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	background-color: transparent;
+}
+
+.broadcast-overlay-header {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	z-index: 30;
+	background-color: transparent;
+}
+
+.broadcast-overlay-message {
+	position: absolute;
+	left: 0;
+	right: 0;
+	z-index: 50;
+	background-color: transparent;
+}
+
+.broadcast-overlay-control {
+	position: absolute;
+	left: 0;
+	right: 0;
+	z-index: 60;
+	background-color: transparent;
+}
+
+.broadcast-overlay-like {
+	position: absolute;
+	z-index: 70;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	width: 84rpx;
+}
+
+.broadcast-like-btn {
+	width: 72rpx;
+	height: 72rpx;
+	border-radius: 36rpx;
+	background-color: rgba(255, 255, 255, 0.06);
+	border: 1rpx solid rgba(255, 255, 255, 0.1);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.broadcast-like-icon {
+	width: 48rpx;
+	height: 48rpx;
+}
+
+.broadcast-like-num {
+	margin-top: 4rpx;
+	color: rgba(255, 255, 255, 0.75);
+	font-size: 20rpx;
+	line-height: 26rpx;
+	text-align: center;
+}
+</style>

+ 55 - 0
components/live-room/bulletin.vue

@@ -0,0 +1,55 @@
+<template>
+	<view class="hl88-lr-bulletin">
+		<view @touchstart="onTouchStart" @touchmove="onTouchMove" class="hl88-lr-bullDrag" :style="{left: touch.left, top: touch.top}">
+			<text style="color: aliceblue;">{{touch}}</text>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: 'LiveRoomBulletin',
+		props: {
+			text: {
+				type: String,
+				default: ''
+			}
+		},
+		data() {
+			return {
+				touch: {
+					left: 0,
+					top: 0,
+					width: 0,
+					height: 0
+				}
+			};
+		},
+		methods: {
+			onTouchStart(e) {
+				this.touch.left = e.touches[0].pageX;
+				this.touch.top = e.touches[0].pageY;
+				// 双击
+				if(e.touches.length == 2){
+					this.touch.width = e.touches[0].pageX - e.touches[1].pageX;
+					this.touch.height = e.touches[0].pageY - e.touches[1].pageY;
+				}
+			},
+			onTouchMove(e) {
+				this.touch.left = e.touches[0].pageX;
+				this.touch.top = e.touches[0].pageY;
+				// 双击
+				if(e.touches.length == 2){
+					this.touch.width = e.touches[0].pageX - e.touches[1].pageX;
+					this.touch.height = e.touches[0].pageY - e.touches[1].pageY;
+				}
+			}
+			
+		}
+	}
+</script>
+
+<style>
+	.hl88-lr-bulletin {
+	}
+</style>

+ 267 - 0
components/live-room/comment.vue

@@ -0,0 +1,267 @@
+
+<template>
+	<view class="hl88-lr-operator" :style="{bottom:statusFootHeight + 60 +'px'}">
+		<view class="hl88-lr-comment">
+			<view class="hl88-lr-comment-item">
+				 <scroll-view scroll-y="true" :scroll-into-view="scrollToView" :scroll-with-animation="true" class="hl88-lr-comment-item-scroll">
+					<!-- #ifdef APP-PLUS -->
+					<view class="hl88-lr-comment-item-scroll-app" v-for="(item, index) in msgList" :key="index" :id="'msg' + index">
+						<view class="hl88-lr-comment-item-scroll-app-item">
+							<text :class="[iscolor(item.form.id)]" class="hl88-lr-msgAppNm">{{item.form.nickname}}:</text>
+							<text class="hl88-lr-msgAppBody">{{item.text}}</text>
+						</view>
+					</view>
+					<!-- #endif -->
+					<!-- #ifdef H5 -->
+					<div class="hl88-lr-comment-item-scroll-h5" v-for="(item, index) in msgList" :key="index" :id="'msg' + index">
+					 	<div class="hl88-lr-comment-item-tag">
+					 		<text :class="[iscolor(item.form.id)]" class="hl88-lr-msgWebNm">{{item.form.nickname}}:</text>
+							<text class="hl88-lr-msgWebBody">{{item.text}}</text>
+					 	</div>
+					</div>
+					<!-- #endif -->
+                    <!-- #ifdef MP -->
+					<div class="hl88-lr-comment-item-scroll-mp" v-for="(item, index) in msgList" :key="index" :id="'msg' + index">
+					 	<div class="hl88-lr-comment-item-tag">
+					 		<text :class="[iscolor(item.form.id)]" class="hl88-lr-msgWebNm">{{item.form.nickname}}:</text>
+							<text class="hl88-lr-msgWebBody">{{item.text}}</text>
+					 	</div>
+					</div>
+                    <!-- #endif -->
+                </scroll-view>
+			</view>
+			<view class="hl88-lr-comment-empty"> </view>
+		</view>
+		<uni-transition :mode-class="['slide-left']" :styles="{ 'position':'absolute', 'left':'0', 'top':'-60rpx', }" :show="comingShow">
+			<view class="hl88-lr-coming">
+				<text class="hl88-lr-coming-text">{{comingName}} 他来了他来了~</text>
+			</view>
+		</uni-transition>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'LiveRoomComment',
+	props: {
+		statusFootHeight: {
+			default: 0
+		},
+		state: {
+			default: 0
+		},
+		liveid: {
+			type: String,
+			default: ''
+		},
+		commentData: {
+			type: Object,
+			default: function() {
+				return {};
+			}
+		}
+	},
+	data() {
+		return {
+			scrollToView: '',
+			comingShow: false,
+			comingName: '',
+			watchTimer: null,
+			msgList: [],
+			liveData: {
+				state: 0,
+				liveid: ''
+			}
+		};
+	},
+	created(){
+		this.liveData.state = this.state;
+		this.liveData.liveid = this.liveid;
+		// 监听直播消息
+		uni.$on('onLive', this.updateMsg);
+		// #ifndef APP-PLUS
+		setTimeout(() => {
+			this.msgList.push({
+				form: {
+					id:0,
+					nickname: 'Tips'
+				},
+				text: 'Please confirm that the purchased items match the host"s description when placing an order. Offline transactions are prohibited, beware of being deceived!'
+			});
+		}, 500);
+		// #endif
+	},
+	methods: {
+		updateMsg(msg){
+			if (this.liveData.state == 1) {
+				if (msg.group == this.liveData.liveid) {
+					var message = msg.message;
+					var form = msg.form;
+					if (message.type == 'msg' || message.type == 'seek' || message.type == 'follow' || message.type == 'like' || message.type == 'review') {
+						this.msgList.push({
+							form: form,
+							text: message.text
+						});
+						this.$nextTick(() => {
+							this.scrollToView = 'msg' + (this.msgList.length - 1);
+						});
+					}
+					if(message.type == 'coming'){
+						if (this.comingShow ) {
+							clearTimeout(this.watchTimer);
+							this.comingShow = false;
+						}
+						this.comingName = form.nickname;
+						this.openComing();
+					}
+				}else{
+					console.log('其他直播间消息');
+				}
+			}
+		},
+		openComing(){
+			this.comingShow = true;
+			this.watchTimer = setTimeout(() => {
+				this.comingShow = false;
+			}, 1000);
+		},
+		iscolor(id){
+			return ['text-white','text-orange','text-yellow','text-green','text-olive','text-green','text-purple','text-pink','text-pink','text-purple'][parseInt(id % 10)]
+		}
+	}
+};
+</script>
+
+<style scoped>
+
+
+.hl88-lr-operator{
+	position: absolute;
+	bottom: 0;
+	left: 0;
+	right: 0;
+}
+	.hl88-lr-coming{
+		margin-left: 25rpx;
+		background-color: rgba(255,86,136,.7);
+		padding-top: 8rpx;
+		padding-right: 16rpx;
+		padding-bottom: 8rpx;
+		padding-left: 16rpx;
+		border-radius: 10rpx;
+	}
+		.hl88-lr-coming-text{
+			color: #ffffff;
+			font-size: 28rpx;
+		}
+	
+	.hl88-lr-comment{
+		/* #ifndef APP-PLUS */
+		-webkit-mask: -webkit-gradient(linear,left 30%,left top,from(#000),to(transparent));
+		/* #endif */
+		margin-left: 25rpx;
+		flex-direction: row;
+		position: relative;
+	}
+		.hl88-lr-comment-empty{
+			flex: 1;
+		}
+		.hl88-lr-comment-item{
+			flex: 2;
+			overflow: hidden;
+			color: #FFFFFF;
+		}
+			.hl88-lr-comment-item-scroll{
+				/* #ifdef H5 || MP */
+				max-height: 500rpx;
+				min-height: 60rpx;
+				/* #endif */
+				/* #ifdef APP-PLUS */
+				height: 500rpx;
+				/* #endif */
+			}
+			
+			
+				
+			/* #ifdef H5 || MP */
+			.hl88-lr-comment-item-scroll-h5{
+				margin-top: 6rpx;
+				overflow: hidden;
+				display: flex;
+			}
+			.hl88-lr-comment-item-scroll-mp{
+				margin-bottom: 6rpx;
+				overflow: hidden; 
+				display: block;
+			}
+				.hl88-lr-comment-item-tag{
+					background-color: rgba(0,0,0,.2);
+					padding-top: 8rpx;
+					padding-right: 16rpx;
+					padding-bottom: 8rpx;
+					padding-left: 16rpx;
+					border-radius: 10rpx;
+					overflow: hidden;
+					display: inline-block;
+				}
+			/* #endif */
+				
+			
+				
+				
+			.hl88-lr-comment-item-scroll-app{
+				flex-direction: row;
+				margin-top: 6rpx;
+			}
+				.hl88-lr-comment-item-scroll-app-item{
+					background-color: rgba(0,0,0,.2); 
+					padding-top: 8rpx;
+					padding-right: 16rpx;
+					padding-bottom: 8rpx;
+					padding-left: 16rpx;
+					border-radius: 10rpx;
+					flex-direction: row;
+				}
+	.hl88-lr-msgAppNm{
+		font-size: 29rpx;
+	}
+	.hl88-lr-msgAppBody{
+		font-size: 29rpx;
+		color: #ffffff;
+	}
+	.hl88-lr-msgWebNm{
+		font-size: 26rpx;
+	}
+	.hl88-lr-msgWebBody{
+		font-size: 26rpx;
+		color: #ffffff;
+	}
+
+.text-white{
+	color: #ffffff;
+}
+.text-orange{
+	color: #f56f23;
+}
+
+.text-yellow{
+	color: #ffee8a;
+}
+
+.text-olive{
+	color: #7ed321;
+}
+
+.text-green{
+	color: #50e3c2;
+}
+
+.text-purple{
+	color: #9134fc;
+}
+
+.text-pink{
+	color: #ff9fbf;
+}
+</style>
+

+ 1751 - 0
components/live-room/control.vue

@@ -0,0 +1,1751 @@
+
+<template>
+	<view class="hl88-lr-control">
+		<!-- 底部菜单 -->
+		<view class="hl88-lr-control-main" :class="{ 'hl88-lr-control-main--live': state == 1 && showCommerceToolbar }">
+			<view class="hl88-lr-control-left">
+				<view v-if="showCommerceToolbar && state == 1" class="hl88-lr-btn-icon" @tap="handlePopup('goods','open')">
+					<image class="hl88-lr-btn-icon-img" src="/static/images/live/goods.png" mode="aspectFit" />
+				</view>
+				<view class="hl88-lr-btn-icon" @tap="handleLive('snapshot')">
+					<image class="hl88-lr-btn-icon-img" src="/static/imgs/save_screenshot.png" mode="aspectFit" />
+				</view>
+				<view class="hl88-lr-btn-icon" @tap="handleLive('switch')">
+					<image class="hl88-lr-btn-icon-img" src="/static/imgs/camera_flip.png" mode="aspectFit" />
+				</view>
+			</view>
+			<view class="hl88-lr-control-right">
+				<view class="hl88-lr-control-right-start hl88-lr-control-right-start--primary" v-if="state == 0" @tap="handlePopup('start','open')">
+					<text class="hl88-lr-control-right-start-text">{{ startCopy.startBtn }}</text>
+				</view>
+				<view class="hl88-lr-control-right-list" v-if="showCommerceToolbar && state == 1">
+					<view class="hl88-lr-control-right-list-btn" @tap="handlePopup('livegoods','open')">
+						<view class="hl88-lr-btn-icon hl88-lr-btn-ghost">
+							<image class="hl88-lr-btn-icon-img hl88-lr-btn-icon-img--shop" src="/static/images/live/shop.png" mode="aspectFit" />
+							<view class="hl88-lr-btn-badge" v-if="goodsSelected.length > 0">
+								<text class="hl88-lr-btn-badge-text">{{numFormat(goodsSelected.length)}}</text>
+							</view>
+						</view>
+					</view>
+				</view>
+			</view>
+		</view>
+		
+		<!-- 准备直播 :maskClick='false' --> 
+		<view class="hl88-lr-ctlPopHost">
+			<uni-popup ref="start" type="bottom">
+				<view class="hl88-lr-popup hl88-lr-popup--start" :style="startPopupWrapStyle">
+					<view class="hl88-lr-start-hero">
+						<view class="hl88-lr-start-hero-main">
+							<text class="hl88-lr-start-hero-badge">{{ startCopy.badge }}</text>
+							<text class="hl88-lr-start-hero-title">{{ startCopy.title }}</text>
+						</view>
+						<view class="hl88-lr-start-hero-close" @tap="handlePopup('start','close')">
+							<image class="hl88-lr-start-hero-close-img" src="/static/imgs/close.png" mode="aspectFit" />
+						</view>
+					</view>
+					<scroll-view 
+						:show-scrollbar="false" 
+						scroll-y="true" 
+						class="hl88-lr-start-scroll" 
+						:style="startScrollStyle"
+						:scroll-into-view="scrollIntoView"
+						:scroll-with-animation="true"
+					>
+						<view class="hl88-lr-start-body" :style="startBodyStyle">
+							<view class="hl88-lr-start-card">
+								<view class="hl88-lr-start-card-hd">
+									<text class="hl88-lr-start-card-icon">🖼</text>
+									<view class="hl88-lr-start-card-meta">
+										<text class="hl88-lr-start-card-title">{{ startCopy.cover }}</text>
+										<text class="hl88-lr-start-card-hint">{{ startCopy.coverHint }}</text>
+									</view>
+								</view>
+								<view class="hl88-lr-start-cover-wrap" :style="startCoverStyle" @tap="uploadCover()">
+									<image class="hl88-lr-start-cover-img" :style="startCoverStyle" :src="stcOss(start.image)" mode="aspectFill" />
+								</view>
+							</view>
+							<view v-if="showPaymentSettings" class="hl88-lr-start-card">
+								<view class="hl88-lr-start-card-hd">
+									<text class="hl88-lr-start-card-icon">💎</text>
+									<view class="hl88-lr-start-card-meta">
+										<text class="hl88-lr-start-card-title">{{ startCopy.payment }}</text>
+									</view>
+								</view>
+								<view class="hl88-lr-start-segment">
+									<view
+										class="hl88-lr-start-segment-item"
+										:class="start.is_paid == 0 ? 'hl88-lr-start-segment-item--active' : ''"
+										@tap="handlePaymentChange(0)"
+									>
+										<text
+											class="hl88-lr-start-segment-text"
+											:class="start.is_paid == 0 ? 'hl88-lr-start-segment-text--active' : ''"
+										>{{ startCopy.free }}</text>
+									</view>
+									<view
+										class="hl88-lr-start-segment-item"
+										:class="start.is_paid == 1 ? 'hl88-lr-start-segment-item--active' : ''"
+										@tap="handlePaymentChange(1)"
+									>
+										<text
+											class="hl88-lr-start-segment-text"
+											:class="start.is_paid == 1 ? 'hl88-lr-start-segment-text--active' : ''"
+										>{{ startCopy.paid }}</text>
+									</view>
+								</view>
+								<view
+									class="hl88-lr-start-field"
+									v-if="start.is_paid == 1"
+									id="paidAmountInput"
+								>
+									<text class="hl88-lr-start-field-label">{{ startCopy.paidAmount }}</text>
+									<input 
+										class="hl88-lr-start-field-input"
+										type="number"
+										:style="startFieldStyle"
+										v-model.number="start.paid_money"
+										:placeholder="startCopy.paidAmountPh"
+										placeholder-class="hl88-lr-start-field-ph"
+										:adjust-position="startPopupInputAdjustPosition"
+										:cursor-spacing="8"
+										@focus="onPaidAmountFocus"
+										@blur="onPaymentInputBlur"
+									/>
+								</view>
+								<view
+									class="hl88-lr-start-field"
+									v-if="start.is_paid == 1"
+									id="freeWatchTimeInput"
+								>
+									<text class="hl88-lr-start-field-label">{{ startCopy.freePreview }}</text>
+									<input 
+										class="hl88-lr-start-field-input"
+										type="number"
+										:style="startFieldStyle"
+										v-model.number="start.free_look_time"
+										:placeholder="startCopy.freePreviewPh"
+										placeholder-class="hl88-lr-start-field-ph"
+										:adjust-position="startPopupInputAdjustPosition"
+										:cursor-spacing="8"
+										@focus="onFreeWatchTimeFocus"
+										@blur="onPaymentInputBlur"
+									/>
+								</view>
+							</view>
+							<view class="hl88-lr-start-card" id="startThemeIntro">
+								<view class="hl88-lr-start-card-hd">
+									<text class="hl88-lr-start-card-icon">✎</text>
+									<view class="hl88-lr-start-card-meta">
+										<text class="hl88-lr-start-card-title">{{ startCopy.theme }}</text>
+									</view>
+									<text class="hl88-lr-start-theme-count">{{ themeIntroLength }}/50</text>
+								</view>
+								<textarea 
+									class="hl88-lr-start-theme-input"
+									:style="startFieldStyle"
+									:placeholder="startCopy.themePh"
+									placeholder-class="hl88-lr-start-field-ph"
+									:adjust-position="startPopupInputAdjustPosition"
+									:cursor-spacing="12"
+									confirm-type="done"
+									maxlength="50"
+									v-model="start.content"
+									@focus="onThemeIntroFocus"
+									@blur="onThemeIntroBlur"
+									@input="onThemeIntroInput"
+								/>
+							</view>
+							<view :style="{ width: '100%', height: startKeyboardSpacerHeight + 'px' }"></view>
+						</view>
+					</scroll-view>
+					<view class="hl88-lr-start-foot" :style="startFootStyle">
+						<view class="hl88-lr-start-submit" @tap="goLive()">
+							<text class="hl88-lr-start-submit-text">{{ startCopy.startBtn }}</text>
+						</view>
+					</view>
+				</view>
+			</uni-popup>
+		</view>
+		
+		<!-- 商品列表 -->
+		<view class="hl88-lr-ctlPopHost">
+			<uni-popup ref="goods" type="bottom">
+				<view class="hl88-lr-popup">
+					<view class="hl88-lr-popup-title">
+						<text class="hl88-lr-popup-title-text">Products</text>
+						<text class="hl88-lr-popup-title-close-text" @tap="handlePopup('goods','close')">&#xe66b;</text>
+					</view>
+					<!-- 搜索条件-->
+					<view class="hl88-lr-search">
+						<picker mode="selector" class="hl88-lr-search-picker" :range='shopSelected.categoryList' range-key="name" selector-type="select" @change="searchPicker">
+							<text class="hl88-lr-search-picker-text">{{search.category_name !== null ? search.category_name : 'All'}}</text>
+							<text class="hl88-lr-search-picker-icon">&#xe65c;</text>
+						</picker>
+						<input 
+							class="hl88-lr-search-input" 
+							type="text"
+							:adjust-position="false"
+							confirm-type="search"
+							v-model="search.q"
+						>
+						<view class="hl88-lr-search-input-icon" @tap="searchGoods()">
+							<text class="hl88-lr-search-input-icon-text">&#xe684;</text>
+						</view>
+					</view>
+					<view class="hl88-lr-search-title">
+						<!-- <text class="hl88-lr-search-title-icon-text">&#xe68b;</text> -->
+						<text class="hl88-lr-search-title-text">- Selected {{goodsSelected.length}} piece -</text>
+					</view>
+					<scroll-view :show-scrollbar="false" scroll-y="true" class="hl88-lr-popup-list" :style="{height: screenHeight * 0.57 +'px'}">
+						<view class="hl88-lr-goods-list-item" :class="[item.choose ? 'hl88-lr-goods-list-item-active':'']" v-for="(item, index) in goodsData" :key="item.id" @tap="handleGoods(index)">
+							<view class="hl88-lr-goods-list-item-img">
+								<image :src="stcOss(item.image)" class="hl88-lr-goods-list-item-image"></image>
+							</view>
+							<view class="hl88-lr-goods-list-item-subject">
+								<view class="hl88-lr-goods-list-item-subject-title">
+									<text class="hl88-lr-goods-list-item-subject-title-text">{{formatEllipsis(item.title, 28)}}</text>
+								</view>
+								<view class="hl88-lr-goods-list-item-subject-operation">
+									<view class="hl88-lr-goods-list-item-subject-operation-price">
+										<text class="hl88-lr-goods-list-item-subject-operation-price-text">HKD{{item.price}}</text>
+									</view>
+									<view class="hl88-lr-operation-button">
+										<text v-if="item.choose" class="hl88-lr-operation-button-text">&#xe67c;</text>
+										<text v-else class="hl88-lr-operation-button-text">&#xe692;</text>
+									</view>
+								</view>
+							</view>
+						</view>
+						<view v-if="goodsData.length == 0" class="hl88-lr-goods-list-empty">
+							<text class="hl88-lr-goods-list-empty-icon">&#xe610;</text>
+							<text class="hl88-lr-goods-list-empty-text">No products found yet</text>
+						</view>
+					</scroll-view>
+					<view class="hl88-lr-popup-start hl88-lr-control-right-start" :style="{bottom: statusFootHeight + 9 + 'px'}" @tap="handlePopup('goods','close')">
+						<text class="hl88-lr-control-right-start-text">OK</text>
+					</view>
+					<!-- 多终端兼容性 -->
+					<view :style="{height: statusFootHeight + 'px'}"></view>
+				</view>
+			</uni-popup>
+		</view>
+		
+		<!-- 直播购物袋 -->
+		<view class="hl88-lr-ctlPopHost">
+			<uni-popup ref="livegoods" type="bottom">
+				<view class="hl88-lr-popup">
+					<view class="hl88-lr-popup-title">
+						<text class="hl88-lr-popup-title-text">Live Shopping Bag</text>
+						<text class="hl88-lr-popup-title-close-text" @tap="handlePopup('livegoods','close')">&#xe66b;</text>
+					</view>
+					<scroll-view :show-scrollbar="false" scroll-y="true" class="hl88-lr-popup-list" :style="{height: screenHeight * 0.6 +'px'}">
+						<view class="hl88-lr-goods-list-item" v-for="(item, index) in goodsSelected" :key="item.id">
+							<view class="hl88-lr-goods-list-item-img">
+								<image :src="stcOss(item.image)" class="hl88-lr-goods-list-item-image"></image>
+							</view>
+							<view class="hl88-lr-rank-tag" :class="['tagbg_' + (index + 1)]">
+								<text class="hl88-lr-rank-tag-text">{{index + 1}}</text>
+							</view>
+							<view class="hl88-lr-goods-list-item-subject">
+								<view class="hl88-lr-goods-list-item-subject-title">
+									<text class="hl88-lr-goods-list-item-subject-title-text">{{formatEllipsis(item.title, 28)}}</text>
+								</view>
+								<view class="hl88-lr-goods-list-item-subject-operation">
+									<view class="hl88-lr-goods-list-item-subject-operation-price">
+										<text class="hl88-lr-goods-list-item-subject-operation-price-text">HKD{{item.price}}</text>
+									</view>
+									<view class="hl88-lr-operation-button" @tap="handleGoodsSelected(item.id)">
+										<text class="hl88-lr-operation-button-text">&#xe724;</text>
+									</view>
+								</view>
+							</view>
+						</view>
+					</scroll-view>
+					<view class="hl88-lr-popup-start hl88-lr-control-right-start" :style="{bottom: statusFootHeight + 9 + 'px'}" @tap="handlePopup('livegoods','close')">
+						<text class="hl88-lr-control-right-start-text">OK</text>
+					</view>
+					<!-- 多终端兼容性 -->
+					<view :style="{height: statusFootHeight + 'px'}"></view>
+				</view>
+			</uni-popup>
+		</view>
+		
+		<!-- 店铺列表 -->
+		<view class="hl88-lr-ctlPopHost">
+			<uni-popup ref="shop" type="bottom">
+				<view class="hl88-lr-popup">
+					<view class="hl88-lr-popup-title">
+						<text class="hl88-lr-popup-title-text">Shop List</text>
+						<text class="hl88-lr-popup-title-close-text" @tap="handlePopup('shop','close')">&#xe66b;</text>
+					</view>
+					<scroll-view :show-scrollbar="false" scroll-y="true" class="hl88-lr-popup-list" :style="{height: screenHeight * 0.6 +'px'}">
+						<view class="hl88-lr-shop-list-item" :class="[item.choose ? 'hl88-lr-shop-list-item-active':'']" v-for="(item, index) in shopList" :key="item.id" @tap="handleShop(index)">
+							<view class="hl88-lr-shop-list-item-img">
+								<image :src="stcOss(item.avatar)" class="hl88-lr-shop-list-item-image"></image>
+							</view>
+							<view class="hl88-lr-shop-list-item-title">
+								<text class="hl88-lr-shop-list-item-title-text">{{item.shopname}}</text>
+							</view>
+							<view class="hl88-lr-operation-button hl88-lr-shop-list-item-button">
+								<text v-if="item.choose" class="hl88-lr-operation-button-text">&#xe67c;</text>
+								<text v-else class="hl88-lr-operation-button-text">&#xe692;</text>
+							</view>
+						</view>
+						<view v-if="shopList.length == 0" class="hl88-lr-goods-list-empty">
+						<text class="hl88-lr-goods-list-empty-icon">&#xe610;</text>
+						<text class="hl88-lr-goods-list-empty-text">No live streaming permission or shop not created yet</text>
+					</view>
+					</scroll-view>
+					<!-- 多终端兼容性 -->
+					<view :style="{height: statusFootHeight + 'px'}"></view>
+				</view>
+			</uni-popup>
+		</view>
+	</view>
+</template>
+
+<script>
+	import { fetchUploadConfig, uploadImageFile } from '@/features/dynamics/publish-gateway'
+	import { fetchBroadcastPermission } from '@/features/live/broadcast-gateway'
+	import {
+		pullRealnameFlagFromApi,
+		REALNAME_APPROVED
+	} from '@/features/mine-panel/realname-bridge'
+
+	export default {
+		name: 'LiveRoomControl',
+		props: {
+			screenHeight: {
+				default: 0
+			},
+			screenWidth: {
+				default: 0
+			},
+			statusFootHeight: {
+				default: 0
+			},
+			liveId: {
+				default: 0
+			},
+			like: {
+				type: Number,
+				default: 0
+			},
+			state: {
+				default: 0
+			}
+		},
+		beforeCreate() {
+			// #ifdef APP-NVUE
+			var domModule = weex.requireModule('dom');
+			domModule.addRule('fontFace', {
+				fontFamily: 'iconfont',
+				src: "url('/static/style/iconfont.ttf')"
+			});
+			// #endif
+		},
+		data() {
+			return {
+				/** 商品/购物袋入口,暂时隐藏,后续开放时改为 true */
+				showCommerceToolbar: false,
+				/** 付费设置,暂时隐藏,后续开放时改为 true */
+				showPaymentSettings: false,
+				search: {
+					q: null,
+					shop_category_id: null,
+					category_name: null
+				},
+				scrollIntoView: '',
+				start: {
+					content: 'Wow~ the live stream has new arrivals~',
+					image: null,
+					goods_ids: null,
+					is_paid: 0, // 是否付费,0=>免费,1=>付费
+					paid_money: 0, // 
+					free_look_time: 60 // 免费看直播 时间(秒),默认60秒
+				},
+				message: null,
+				shopList: [],
+				shopSelected: {
+					id: null,
+					categoryList: []
+				},
+				goodsData: [],
+				goodsSelected: [],
+				/** 开始直播弹窗:系统键盘高度(App/小程序由 onKeyboardHeightChange 更新) */
+				startKeyboardHeight: 0,
+				_startKeyboardHeightListener: null,
+				/** 仅在为 true 时应用底部键盘留白,简介输入时不为 true */
+				startPaymentInputFocused: false,
+				startPaymentBlurTimer: null,
+				startThemeIntroFocused: false,
+				startThemeIntroBlurTimer: null,
+				startCopy: {
+					badge: 'LIVE',
+					title: 'Prepare broadcast',
+					cover: 'Cover',
+					coverHint: 'Upload cover (16:9)',
+					payment: 'Access',
+					free: 'Free',
+					paid: 'Paid',
+					paidAmount: 'Paid amount',
+					paidAmountPh: 'Enter amount',
+					freePreview: 'Free preview (sec)',
+					freePreviewPh: 'e.g. 60',
+					theme: 'Title',
+					themePh: 'Live title (max 50 chars)',
+					startBtn: 'Start live'
+				}
+			};
+		},
+		computed: {
+			startPanelWidthPx() {
+				const w = Number(this.screenWidth)
+				if (w > 0) return w
+				try {
+					return uni.getSystemInfoSync().screenWidth || uni.upx2px(750)
+				} catch (_) {
+					return uni.upx2px(750)
+				}
+			},
+			startPopupWrapStyle() {
+				return {
+					width: this.startPanelWidthPx + 'px',
+					paddingBottom: this.startFootPadding + 'px'
+				}
+			},
+			startScrollStyle() {
+				return {
+					width: this.startPanelWidthPx + 'px',
+					height: this.startScrollHeight + 'px'
+				}
+			},
+			startBodyStyle() {
+				return {
+					width: this.startPanelWidthPx + 'px'
+				}
+			},
+			startFieldStyle() {
+				const inner = this.startPanelWidthPx - uni.upx2px(56 + 48)
+				return {
+					width: Math.max(200, inner) + 'px'
+				}
+			},
+			startCoverStyle() {
+				const inner = this.startPanelWidthPx - uni.upx2px(56 + 48)
+				const width = Math.max(200, inner)
+				const height = Math.round(width * 9 / 16)
+				return {
+					width: width + 'px',
+					height: Math.max(120, height) + 'px'
+				}
+			},
+			startFootStyle() {
+				return {
+					width: this.startPanelWidthPx + 'px',
+					paddingBottom: (Number(this.statusFootHeight) || 0) + 16 + 'px'
+				}
+			},
+			startFootPadding() {
+				return uni.upx2px(120) + (Number(this.statusFootHeight) || 0)
+			},
+			startScrollHeight() {
+				const h = Number(this.screenHeight) || 667
+				const popupMax = h * 0.78
+				const heroH = uni.upx2px(220)
+				const footH = uni.upx2px(132) + (Number(this.statusFootHeight) || 0)
+				return Math.max(240, popupMax - heroH - footH)
+			},
+			themeIntroLength() {
+				const s = this.start && this.start.content != null ? String(this.start.content) : '';
+				return s.length;
+			},
+			/** 付费或简介聚焦时增加 scroll-view 底部留白(与 onKeyboardHeightChange 配合) */
+			startKeyboardSpacerHeight() {
+				if (!this.startPaymentInputFocused && !this.startThemeIntroFocused) return 0;
+				const h = this.startKeyboardHeight;
+				if (!h || h <= 0) return 0;
+				return Math.max(0, h * 0.88 - uni.upx2px(8));
+			},
+			/** H5 用系统顶起;App 弹层内用底部留白 + scroll-into-view,避免与系统顶起冲突 */
+			startPopupInputAdjustPosition() {
+				// #ifdef H5
+				return true;
+				// #endif
+				return false;
+			}
+		},
+		mounted() {
+			this.startCopy = this.buildStartCopy();
+			this._onBroadcastStartOk = () => {
+				this.handlePopup('start', 'close');
+			};
+			uni.$on('liveBroadcastStartOk', this._onBroadcastStartOk);
+			// #ifndef H5
+			if (typeof uni.onKeyboardHeightChange === 'function') {
+				this._startKeyboardHeightListener = (res) => {
+					const h = res && res.height ? res.height : 0;
+					this.startKeyboardHeight = h;
+					if (h <= 0) {
+						this.startPaymentInputFocused = false;
+						this.startThemeIntroFocused = false;
+						if (this.startPaymentBlurTimer) {
+							clearTimeout(this.startPaymentBlurTimer);
+							this.startPaymentBlurTimer = null;
+						}
+						if (this.startThemeIntroBlurTimer) {
+							clearTimeout(this.startThemeIntroBlurTimer);
+							this.startThemeIntroBlurTimer = null;
+						}
+					}
+				};
+				uni.onKeyboardHeightChange(this._startKeyboardHeightListener);
+			}
+			// #endif
+		},
+		beforeDestroy() {
+			if (this._onBroadcastStartOk) {
+				uni.$off('liveBroadcastStartOk', this._onBroadcastStartOk);
+				this._onBroadcastStartOk = null;
+			}
+			if (this.startPaymentBlurTimer) {
+				clearTimeout(this.startPaymentBlurTimer);
+				this.startPaymentBlurTimer = null;
+			}
+			if (this.startThemeIntroBlurTimer) {
+				clearTimeout(this.startThemeIntroBlurTimer);
+				this.startThemeIntroBlurTimer = null;
+			}
+			this.startPaymentInputFocused = false;
+			this.startThemeIntroFocused = false;
+			// #ifndef H5
+			if (this._startKeyboardHeightListener && typeof uni.offKeyboardHeightChange === 'function') {
+				uni.offKeyboardHeightChange(this._startKeyboardHeightListener);
+				this._startKeyboardHeightListener = null;
+			}
+			this.startKeyboardHeight = 0;
+			// #endif
+		},
+		methods: {
+			buildStartCopy() {
+				const lang = uni.getLocale();
+				if (lang === 'zh-Hans') {
+					return {
+						badge: 'LIVE',
+						title: '准备开播',
+						cover: '直播封面',
+						coverHint: '上传封面图(16:9)',
+						payment: '观看方式',
+						free: '免费',
+						paid: '付费',
+						paidAmount: '付费金额',
+						paidAmountPh: '请输入金额',
+						freePreview: '免费试看(秒)',
+						freePreviewPh: '例如 60',
+						theme: '直播标题',
+						themePh: '填写直播主题(最多50字)',
+						startBtn: '开始直播'
+					};
+				}
+				if (lang === 'zh-Hant') {
+					return {
+						badge: 'LIVE',
+						title: '準備開播',
+						cover: '直播封面',
+						coverHint: '上傳封面圖(16:9)',
+						payment: '觀看方式',
+						free: '免費',
+						paid: '付費',
+						paidAmount: '付費金額',
+						paidAmountPh: '請輸入金額',
+						freePreview: '免費試看(秒)',
+						freePreviewPh: '例如 60',
+						theme: '直播標題',
+						themePh: '填寫直播主題(最多50字)',
+						startBtn: '開始直播'
+					};
+				}
+				return {
+					badge: 'LIVE',
+					title: 'Prepare broadcast',
+					cover: 'Cover',
+					coverHint: 'Upload cover (16:9)',
+					payment: 'Access',
+					free: 'Free',
+					paid: 'Paid',
+					paidAmount: 'Paid amount',
+					paidAmountPh: 'Enter amount',
+					freePreview: 'Free preview (sec)',
+					freePreviewPh: 'e.g. 60',
+					theme: 'Title',
+					themePh: 'Live title (max 50 chars)',
+					startBtn: 'Start live'
+				};
+			},
+			clearStartPaymentBlurTimer() {
+				if (this.startPaymentBlurTimer) {
+					clearTimeout(this.startPaymentBlurTimer);
+					this.startPaymentBlurTimer = null;
+				}
+			},
+			clearStartThemeIntroBlurTimer() {
+				if (this.startThemeIntroBlurTimer) {
+					clearTimeout(this.startThemeIntroBlurTimer);
+					this.startThemeIntroBlurTimer = null;
+				}
+			},
+			onThemeIntroBlur() {
+				this.clearStartThemeIntroBlurTimer();
+				this.startThemeIntroBlurTimer = setTimeout(() => {
+					this.startThemeIntroFocused = false;
+					this.startThemeIntroBlurTimer = null;
+				}, 160);
+			},
+			onPaymentInputBlur() {
+				this.clearStartPaymentBlurTimer();
+				this.startPaymentBlurTimer = setTimeout(() => {
+					this.startPaymentInputFocused = false;
+					this.startPaymentBlurTimer = null;
+				}, 160);
+			},
+			// 处理付费状态切换
+			handlePaymentChange(value) {
+				this.start.is_paid = value;
+				// 如果切换到付费,延迟滚动到输入框位置
+				if (value == 1) {
+					// 如果免费观看时长为0或未设置,设置默认值为60秒
+					if (!this.start.free_look_time || this.start.free_look_time <= 0) {
+						this.start.free_look_time = 60;
+					}
+					this.$nextTick(() => {
+						setTimeout(() => {
+							this.scrollToPaymentInputs();
+						}, 200);
+					});
+				}
+			},
+			// 滚动到付费输入框位置
+			scrollToPaymentInputs() {
+				this.scrollStartPopupToField('paidAmountInput');
+			},
+			/** 将开始直播弹窗滚动到指定字段(scroll-view 子节点 id) */
+			scrollStartPopupToField(fieldId) {
+				this.$nextTick(() => {
+					setTimeout(() => {
+						this.scrollIntoView = fieldId;
+						setTimeout(() => {
+							this.scrollIntoView = '';
+						}, 450);
+					}, 120);
+				});
+			},
+			onPaidAmountFocus(e) {
+				this.clearStartPaymentBlurTimer();
+				this.clearStartThemeIntroBlurTimer();
+				this.startThemeIntroFocused = false;
+				this.startPaymentInputFocused = true;
+				const h = e && e.detail && typeof e.detail.height === 'number' ? e.detail.height : 0;
+				if (h > 0) this.startKeyboardHeight = h;
+				this.scrollStartPopupToField('paidAmountInput');
+			},
+			onFreeWatchTimeFocus(e) {
+				this.clearStartPaymentBlurTimer();
+				this.clearStartThemeIntroBlurTimer();
+				this.startThemeIntroFocused = false;
+				this.startPaymentInputFocused = true;
+				const h = e && e.detail && typeof e.detail.height === 'number' ? e.detail.height : 0;
+				if (h > 0) this.startKeyboardHeight = h;
+				this.scrollStartPopupToField('freeWatchTimeInput');
+			},
+			/** 简介聚焦:与付费区共用底部留白(App),略延迟再 scroll-into-view 等布局稳定 */
+			onThemeIntroFocus(e) {
+				this.clearStartPaymentBlurTimer();
+				this.clearStartThemeIntroBlurTimer();
+				this.startPaymentInputFocused = false;
+				this.startThemeIntroFocused = true;
+				const h = e && e.detail && typeof e.detail.height === 'number' ? e.detail.height : 0;
+				if (h > 0) this.startKeyboardHeight = h;
+				this.$nextTick(() => {
+					setTimeout(() => {
+						this.scrollIntoView = 'startThemeIntro';
+						setTimeout(() => {
+							this.scrollIntoView = '';
+						}, 520);
+					}, 200);
+				});
+			},
+			/** 简介最多 50 字(部分端 maxlength 与中文输入法偶发不同步,此处兜底截断) */
+			onThemeIntroInput(e) {
+				const maxLen = 50;
+				const v = e.detail && e.detail.value != null ? e.detail.value : this.start.content;
+				if (v && v.length > maxLen) {
+					this.start.content = v.slice(0, maxLen);
+				}
+			},
+			// 开始直播(接口失败或未完成实名认证时不关闭弹窗、不推流)
+			async goLive() {
+				const lang = uni.getLocale();
+				if (!this.start.image) {
+					const t = lang === 'zh-Hans' ? '请上传直播封面' : (lang === 'zh-Hant' ? '請上傳直播封面' : 'cover cannot be empty');
+					uni.showToast({ title: t, icon: 'none' });
+					return;
+				}
+				if (!this.start.content || !String(this.start.content).trim()) {
+					const t = lang === 'zh-Hans' ? '请填写直播标题' : (lang === 'zh-Hant' ? '請填寫直播標題' : 'Title cannot be empty');
+					uni.showToast({ title: t, icon: 'none' });
+					return;
+				}
+				if (!this.showPaymentSettings) {
+					this.start.is_paid = 0;
+					this.start.paid_money = 0;
+					this.start.free_look_time = 0;
+				} else if (this.start.is_paid == 1) {
+					if (!this.start.paid_money || this.start.paid_money <= 0) {
+						const t = lang === 'zh-Hans' ? '付费金额须大于 0' : (lang === 'zh-Hant' ? '付費金額須大於 0' : 'Paid amount must be greater than 0');
+						uni.showToast({ title: t, icon: 'none' });
+						return;
+					}
+					if (this.start.free_look_time == null || this.start.free_look_time < 0) {
+						const t = lang === 'zh-Hans' ? '试看时长不能为负' : (lang === 'zh-Hant' ? '試看時長不能為負' : 'Free watch time cannot be negative');
+						uni.showToast({ title: t, icon: 'none' });
+						return;
+					}
+				} else {
+					this.start.paid_money = 0;
+					this.start.free_look_time = 0;
+				}
+				this.start.goods_ids = this.goodsSelected.map((item) => item.id);
+
+				const rn = await pullRealnameFlagFromApi();
+				if (rn.ok && rn.is_real !== REALNAME_APPROVED) {
+					let title = '提示';
+					let content = '请先完成实名认证后再开播';
+					let confirmText = '去认证';
+					let cancelText = '取消';
+					if (lang === 'zh-Hant') {
+						content = '請先完成實名認證後再開播';
+						confirmText = '去認證';
+					} else if (lang !== 'zh-Hans') {
+						content = 'Please complete real-name verification before going live';
+						confirmText = 'Verify';
+						cancelText = 'Cancel';
+					}
+					uni.showModal({
+						title,
+						content,
+						confirmText,
+						cancelText,
+						success: (res) => {
+							if (res.confirm) {
+								uni.navigateTo({ url: '/pages/account/realname-add' });
+							}
+						}
+					});
+					return;
+				}
+
+				this.handleLive('start', this.start);
+			},
+			// 操作弹出框
+			async handlePopup(name, method) {
+				// 关闭商品列表同步云端
+				if(name == 'goods' && method == 'close' && this.state == 1){
+					// 隐藏强制商品选择
+					// if(this.goodsSelected.length < 2){
+					// 	uni.showToast({title: '商品至少选择2件', icon: 'error'});
+					// }else{
+						await uni.request({
+							url: '/wanlshop/live/cloud',
+							data: {
+								id: this.liveId,
+								goods_ids: this.goodsSelected.map(item => { return item.id })
+							}
+						});
+						this.$refs.goods.close();
+					// }
+				// 请选择直播店铺
+				}
+				// else if(name == 'goods' && method == 'open' && this.state == 0){
+				// 	if (!this.shopSelected.id) {
+				// 		uni.showToast({title: '请选择直播店铺', icon: 'error'});
+				// 		this.handlePopup('shop','open');
+				// 	}else{
+				// 		this.$refs.goods.open();
+				// 	}
+				// // 检查直播权限获取店铺列表、商家类目 ------------
+				// }
+				else if(name == 'start' && method == 'open'){
+					this.startCopy = this.buildStartCopy();
+					try {
+						const list = await fetchBroadcastPermission();
+						if (this.shopList.length === 0) {
+							this.shopList = list;
+						}
+						this.$refs.start.open();
+					} catch (_) {
+						// 接口失败(含未实名等)已由拦截器提示,不打开开播弹窗
+					}
+				}else{
+					this.$refs[name][method]();
+				}
+			},
+			handleShop(index){
+				var j, len;
+				for(j = 0, len = this.shopList.length; j < len; j++) {
+					this.shopList[j].choose = j == index ? true : false;
+				}
+				this.shopSelected = this.shopList[index];
+				this.handleLive('user', this.shopSelected);
+				this.handlePopup('shop','close');
+			},
+			// 监听搜索
+			searchPicker(e){
+				var category = this.shopSelected.categoryList[e.detail.value];
+				this.search.shop_category_id = category.id;
+				this.search.category_name = category.name;
+			},
+			// 获取商品列表,  查询条件 ------------
+			async searchGoods(){
+				await uni.request({
+					url: '/wanlshop/live/goods',
+					data: {
+						shop_id: this.shopSelected.id,
+						q: this.search.q,
+						shop_category_id: this.search.shop_category_id
+					},
+					success: res => {
+						// 通过goodsSelected过滤已选择
+						var selectedIdArr = this.goodsSelected.map(item => { return item.id });
+						res.data.map(item => {
+							item.choose = selectedIdArr.findIndex(id => {if (item.id == id) {return true}}) !== -1 ? true : false;
+						});
+						this.goodsData = res.data;
+					}
+				});
+			},
+			handleGoods(index){
+				if(!this.goodsData[index].choose){
+					this.goodsData[index].choose = true;
+					// 追加到goodsSelected选择器中
+					this.goodsSelected = this.goodsSelected.concat(this.goodsData[index]);
+				}else{
+					this.handleGoodsSelected(this.goodsData[index].id);
+				}
+			},
+			// 删除选择 并取消商品列表勾选
+			handleGoodsSelected(id){
+				var selecteIndex = this.goodsSelected.findIndex(item => {if (item.id == id) {return true}});
+				var goodsIndex = this.goodsData.findIndex(item => {if (item.id == id) {return true}});
+				if(selecteIndex !== -1){
+					this.goodsSelected.splice(selecteIndex, 1);
+				}
+				if(selecteIndex !== -1){
+					this.goodsData[goodsIndex].choose = false;
+				}
+			},
+			// 操作直播
+			handleLive(method, data){
+				this.$emit('change', method, data);
+			},
+			// 传递给live 消息
+			send(type, index){
+				switch(type) {
+					// 发送消息
+					case 'msg': 
+						if (this.message) {
+							this.$emit('send',{
+								type: 'msg',
+								message: this.message
+							});
+							this.message = '';
+						}
+						// 收起键盘
+						uni.hideKeyboard();
+						break;
+					// 点赞
+					case 'like':
+						this.$emit('send',{type: 'like'});
+						break;
+				} 
+			},
+			// 上传封面:App 走系统原生裁剪(不遮挡开播弹窗),其它端直接上传
+			uploadCover() {
+				const opts = {
+					sourceType: ['album', 'camera'],
+					sizeType: ['compressed'],
+					count: 1,
+					success: (res) => {
+						const filePath = res.tempFilePaths && res.tempFilePaths[0]
+						if (filePath) this.uploadCoverFile(filePath)
+					}
+				}
+				// #ifdef APP-PLUS
+				opts.crop = {
+					width: 1280,
+					height: 720,
+					quality: 90
+				}
+				// #endif
+				uni.chooseImage(opts)
+			},
+			async uploadCoverFile(filePath) {
+				if (!filePath) return
+				const lang = uni.getLocale()
+				let loadingTitle = 'Uploading'
+				if (lang === 'zh-Hans') loadingTitle = '上传中'
+				else if (lang === 'zh-Hant') loadingTitle = '上傳中'
+				uni.showLoading({ title: loadingTitle, mask: true })
+				try {
+					const config = await fetchUploadConfig()
+					const url = await uploadImageFile(filePath, config)
+					this.start.image = url
+					let okTitle = 'Uploaded'
+					if (lang === 'zh-Hans') okTitle = '封面上传成功'
+					else if (lang === 'zh-Hant') okTitle = '封面上傳成功'
+					uni.showToast({ title: okTitle, icon: 'success' })
+				} catch (err) {
+					console.error('live cover upload failed', err)
+					let errTitle = 'Upload failed'
+					if (lang === 'zh-Hans') errTitle = '封面上传失败'
+					else if (lang === 'zh-Hant') errTitle = '封面上傳失敗'
+					uni.showToast({
+						title: (err && err.message) ? err.message : errTitle,
+						icon: 'none'
+					})
+				} finally {
+					uni.hideLoading()
+				}
+			},
+			stcOss(url) {
+				let oss = (this.$store.state.shell && this.$store.state.shell.appUrl && this.$store.state.shell.appUrl.oss) || '';
+				let image = '';
+				if (url) {
+					if ((/^(http|https):\/\/.+/.test(url))) {
+						image = url;
+					} else {
+						image = oss + url;
+					}
+				} else {
+					image = oss + '/assets/addons/wanlshop/img/common/img_default3x.png';
+				}
+				return image;
+			},
+			formatEllipsis(str = '', limitLen = 24) {
+				let len = 0,
+				reg = /[\x00-\xff]/, //半角字符的正则匹配
+				strs = str.split(''),
+				inx = strs.findIndex(s => {
+					len += reg.test(s) ? 1 : 2
+					if (len > limitLen) return true
+				})
+			    return inx === -1 ? str : str.substr(0, inx) + '...'
+			},
+			numFormat(num) {
+				return num > 9999 ? ((num - num % 1000) / 10000 + 'w') : num
+			}
+		}
+	};
+</script>
+
+<style>
+@import '@/static/style/wanlnvue.css';
+.hl88-lr-control {
+	background-color: transparent;
+	width: 750rpx;
+}
+.hl88-lr-ctlPopHost {
+	width: 750rpx;
+}
+/* nvue 内嵌 webview:uni-popup 容器须占满屏宽 */
+.hl88-lr-ctlPopHost .uni-popup__wrapper-box {
+	width: 750rpx;
+}
+.hl88-lr-control-main{
+	padding-top: 18rpx;
+	padding-bottom: 18rpx;
+	padding-left: 18rpx;
+	padding-right: 18rpx;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+}
+.hl88-lr-control-main--live{
+	padding-right: 120rpx;
+}
+	.hl88-lr-control-left {
+		flex-direction: row;
+		justify-content: space-between;
+		align-items: center;
+	}
+		.hl88-lr-btn-icon{
+			position: relative;
+			width: 76rpx;
+			height: 76rpx;
+			margin-right: 18rpx;
+			border-radius: 38rpx;
+			background-color: rgba(0, 0, 0, 0.2);
+			align-items: center;
+			justify-content: center;
+		}
+		.hl88-lr-btn-icon-img{
+			width: 44rpx;
+			height: 44rpx;
+		}
+		.hl88-lr-btn-icon-img--shop{
+			width: 48rpx;
+			height: 48rpx;
+		}
+		.hl88-lr-btn-ghost{
+			background-color: transparent;
+		}
+		.hl88-lr-btn-badge{
+			position: absolute;
+			top: -8rpx;
+			right: -8rpx;
+			min-width: 32rpx;
+			height: 32rpx;
+			padding-left: 8rpx;
+			padding-right: 8rpx;
+			border-radius: 16rpx;
+			background-color: #311564;
+			align-items: center;
+			justify-content: center;
+			z-index: 1;
+		}
+		.hl88-lr-btn-badge-text{
+			color: #ffffff;
+			font-size: 20rpx;
+			line-height: 32rpx;
+		}
+		.hl88-lr-btn-icon-text{
+			font-size: 38rpx;
+			color: rgba(255, 255, 255, 0.85);
+		}
+	.hl88-lr-control-right{
+		flex: 1;
+		flex-direction: row;
+		align-items: center;
+		justify-content: flex-end;
+	}
+		.hl88-lr-control-right-start{
+			flex: 1;
+			width: 100%;
+			background: #4a8cff;
+			align-items: center;
+			justify-content: center;
+			height: 76rpx;
+			border-radius: 40rpx;
+		}
+		.hl88-lr-control-right-start--primary {
+			background-color: #4a8cff;
+			background-image: linear-gradient(135deg, #4a8cff 0%, #6ec8ff 100%);
+		}
+			.hl88-lr-control-right-start-text{
+				color: #fff;
+				font-size: 30rpx;
+				font-weight: 600;
+			}
+		.hl88-lr-control-right-list {
+			flex-direction: row;
+			align-items: center;
+			justify-content: flex-end;
+		}
+		.hl88-lr-control-right-list-btn {
+			position: relative;
+		}
+		/* 购物袋颜色 */
+		.hl88-lr-control-right-list-btn-text {
+			color: #3ed656;
+			font-size: 56rpx;
+		}
+/* 弹框 */
+.hl88-lr-popup{
+	position: relative;
+	width: 750rpx;
+	background: rgba(255, 255, 255, 0.93);
+	padding-left: 18rpx;
+	padding-right: 18rpx;
+	border-top-left-radius: 18rpx;
+	border-top-right-radius: 18rpx;
+}
+	.hl88-lr-popup-list{
+		margin-bottom: 108rpx;
+	}
+		.hl88-lr-goods-list-empty {
+			align-items: center;
+			justify-content: center;
+			height: 600rpx;
+		}
+			.hl88-lr-goods-list-empty-icon {
+				font-size: 100rpx;
+				color: #888;
+				/* #ifndef APP-PLUS */
+				font-family: "iconfont";
+				/* #endif*/
+				font-family: iconfont;
+				margin-bottom: 30rpx;
+			}
+			.hl88-lr-goods-list-empty-text {
+				color: #888;
+				font-size: 28rpx;
+			}
+	.hl88-lr-popup-title {
+		height: 100rpx;
+		flex-direction: row;
+		align-items: center;
+		justify-content: space-between;
+	}
+		.hl88-lr-popup-title-text {
+			font-size: 32rpx;
+		}
+		.hl88-lr-popup-title-close-text{
+			font-size: 32rpx;
+			/* #ifndef APP-PLUS */
+			font-family: "iconfont";
+			/* #endif*/
+			font-family: iconfont;
+			width: 40rpx;
+			height: 40rpx;
+		}
+	.hl88-lr-search {
+		flex-direction: row;
+		align-items: center;
+		height: 78rpx;
+		margin-bottom: 40rpx;
+		border-left: 2rpx solid rgba(0, 0, 0, 0.1);
+		border-right: 2rpx solid rgba(0, 0, 0, 0.1);
+		border-top: 2rpx solid rgba(0, 0, 0, 0.1);
+		border-bottom: 2rpx solid rgba(0, 0, 0, 0.1);
+		border-radius: 18rpx;
+		overflow: hidden;
+	}
+		.hl88-lr-search-picker {
+			flex-direction: row;
+			height: 78rpx;
+			line-height: 78rpx;
+			align-items: center;
+			justify-content: center;
+			padding-left: 30rpx;
+			padding-right: 18rpx;
+			margin-right: 18rpx;
+			border-radius: 18rpx;
+			background: rgba(0, 0, 0, 0.06);
+		}
+			.hl88-lr-search-picker-text {
+				color: rgba(0, 0, 0, 0.4);
+				font-size: 30rpx;
+				margin-right: 4rpx;
+			}
+			.hl88-lr-search-picker-icon {
+				font-size: 32rpx;
+				color: rgba(0, 0, 0, 0.15);
+				/* #ifndef APP-PLUS */
+				font-family: "iconfont";
+				/* #endif*/
+				font-family: iconfont;
+			}
+	.hl88-lr-search-input { 
+		flex: 1;
+		color: #333;
+		font-size: 28rpx;
+	}
+	.hl88-lr-search-input-icon {
+		height: 78rpx;
+		line-height: 78rpx;
+		width: 80rpx;
+		align-items: center;
+		justify-content: center;
+	}
+		.hl88-lr-search-input-icon-text {
+			font-size: 38rpx;
+			color: rgba(0, 0, 0, 0.5);
+			/* #ifndef APP-PLUS */
+			font-family: "iconfont";
+			/* #endif*/
+			font-family: iconfont;
+		}
+	.hl88-lr-search-title {
+		flex-direction: row;
+		align-items: center;
+		justify-content: center;
+		margin-bottom: 18rpx;
+	}
+	.hl88-lr-search-title-text {
+		font-size: 28rpx;
+		color: #666;
+	}
+	.hl88-lr-search-title-icon-text {
+		font-size: 24rpx;
+		color: #3ed656;
+		/* #ifndef APP-PLUS */
+		font-family: "iconfont";
+		/* #endif*/
+		font-family: iconfont;
+	}
+	.hl88-lr-popup-start {
+		position: absolute;
+		right: 18rpx;
+		left: 18rpx;
+	}
+	.hl88-lr-operation-button{
+		flex-direction: row;
+		
+	}
+		.hl88-lr-operation-button-text {
+			font-size: 44rpx;
+			color: #3ed656;
+			/* #ifndef APP-PLUS */
+			font-family: "iconfont";
+			/* #endif*/
+			font-family: iconfont;
+		}
+	/* 准备直播 */
+	.hl88-lr-start-list-item-name {
+		flex-direction: row;
+		align-items: center;
+		justify-content: space-between;
+		margin-bottom: 18rpx;
+	}
+	
+		.hl88-lr-start-list-item-name-left{
+			flex-direction: row;
+			align-items: center;
+		}
+			.hl88-lr-start-list-item-name-left-icon-text {
+				font-size: 24rpx;
+				color: #3ed656;
+				/* #ifndef APP-PLUS */
+				font-family: "iconfont";
+				/* #endif*/
+				font-family: iconfont;
+			}
+			.hl88-lr-start-list-item-name-text {
+				font-size: 28rpx;
+			}
+		.hl88-lr-start-list-item-name-right {
+			flex-direction: row;
+			align-items: center;
+		}
+			.hl88-lr-start-list-item-name-right-text{
+				font-size: 26rpx;
+				color: #3ed656;
+			}
+		.hl88-lr-start-theme-count {
+			flex-shrink: 0;
+		}
+			.hl88-lr-start-theme-count-text {
+				font-size: 24rpx;
+				color: #999999;
+			}
+	
+	.hl88-lr-start-list-item-content-select {
+		flex-direction: row;
+		align-items: center;
+		justify-content: center;
+		height: 80rpx;
+		margin-bottom: 18rpx;
+		border-left: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-right: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-top: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-bottom: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-radius: 18rpx;
+	}
+		.hl88-lr-start-list-item-content-select-text {
+			font-size: 26rpx;
+			color: #888;
+			/* #ifndef APP-PLUS */
+			font-family: "iconfont";
+			/* #endif*/
+			font-family: iconfont;
+			padding-right: 8rpx;
+			padding-left: 8rpx;
+		}
+	
+	
+	.hl88-lr-start-list-item-content-img {
+		position: relative;
+		width: 280rpx;
+		height: 350rpx;
+		padding-top: 18rpx;
+		padding-right: 18rpx;
+		padding-bottom: 18rpx;
+		padding-left: 18rpx;
+		border-left: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-right: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-top: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-bottom: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-radius: 18rpx;
+		margin-bottom: 22rpx;
+	}
+		.hl88-lr-start-list-item-content-image {
+			border-radius: 18rpx;
+			width: 240rpx;
+			height: 310rpx;
+		}
+	
+	.hl88-lr-start-list-item-goods-image {
+		margin-bottom: 22rpx;
+	}
+	.hl88-lr-start-list-item-goods-item {
+		flex-direction: row;
+		align-items: center;
+		background: rgba(255, 255, 255, 0.8);
+		padding-top: 18rpx;
+		padding-bottom: 18rpx;
+		padding-left: 18rpx;
+		padding-right: 18rpx;
+		border-radius: 18rpx;
+		margin-bottom: 18rpx;
+	}
+		.hl88-lr-start-list-item-goods-item-img {
+			width: 70rpx;
+			height: 70rpx;
+		}
+			.hl88-lr-start-list-item-goods-item-img-image {
+				width: 70rpx;
+				height: 70rpx;
+				border-radius: 5000rpx;
+			}
+		.hl88-lr-start-list-item-goods-item-title {
+			flex: 1;
+			padding-left: 18rpx;
+			padding-right: 18rpx;
+		}
+			.hl88-lr-start-list-item-goods-item-title-text {
+				color: #333;
+				font-size: 28rpx;
+			}
+		
+	/* Weex 仅支持单类名选择器,勿用 .a.b 链式;简介框合并为一类 */
+	.hl88-lr-start-list-item-content-textarea-intro {
+		font-size: 28rpx;
+		color: #888;
+		height: 120rpx;
+		max-height: 120rpx;
+		border-left: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-right: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-top: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-bottom: 2rpx solid rgba(0, 0, 0, 0.04);
+		padding-top: 18rpx;
+		padding-right: 18rpx;
+		padding-bottom: 18rpx;
+		padding-left: 18rpx;
+		border-radius: 18rpx;
+		margin-bottom: 22rpx;
+	}
+	.hl88-lr-start-list-item-content-placeholder {
+		font-size: 28rpx;
+		color: #888;
+	}
+	
+	/* 商品列表 */
+	.hl88-lr-goods-list-item {
+		flex-direction: row;
+		background: rgba(255, 255, 255, 0.8);
+		border-left: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-right: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-top: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-bottom: 2rpx solid rgba(0, 0, 0, 0.04);
+		padding-top: 18rpx;
+		padding-right: 18rpx;
+		padding-bottom: 18rpx;
+		padding-left: 18rpx;
+		margin-bottom: 18rpx;
+		border-radius: 18rpx;
+	}
+		.hl88-lr-goods-list-item-active {
+			background: rgba(62, 214, 86, 0.12);
+			border-left: 2rpx solid rgba(62, 214, 86, 0.45);
+			border-right: 2rpx solid rgba(62, 214, 86, 0.45);
+			border-top: 2rpx solid rgba(62, 214, 86, 0.45);
+			border-bottom: 2rpx solid rgba(62, 214, 86, 0.45);
+		}
+		.hl88-lr-goods-list-item-img {
+			position: relative;
+			width: 180rpx;
+			height: 180rpx;
+			border-top-left-radius: 12rpx;
+			border-top-right-radius: 12rpx;
+			border-bottom-right-radius: 12rpx;
+			border-bottom-left-radius: 12rpx;
+			overflow: hidden;
+			background-color: #f7f7f7;
+			margin-right: 20rpx;
+		}
+			.hl88-lr-goods-list-item-image{
+				width: 180rpx;
+				height: 180rpx;
+				border-radius: 18rpx;
+			}
+			
+		.hl88-lr-goods-list-item-subject{
+			flex: 1;
+			justify-content: space-between;
+		}
+			
+			.hl88-lr-goods-list-item-subject-title-text{
+				color: rgba(0, 0, 0, 0.7);
+				font-size: 28rpx;
+				/* #ifdef APP-PLUS */
+				/* lines: 2; */
+				/* text-overflow: ellipsis; */
+				line-height: 36rpx;
+				/* #endif */
+			}
+			
+			.hl88-lr-goods-list-item-subject-operation{
+				align-items: center;
+				justify-content: space-between;
+				flex-direction: row;
+			}
+				.hl88-lr-goods-list-item-subject-operation-price-text{
+					color: #2fb04a;
+					font-size: 32rpx;
+					font-weight: 500;
+				}
+			
+	.hl88-lr-shop-list-item {
+		align-items: center;
+		flex-direction: row;
+		margin-bottom: 24rpx;
+		background-color: rgba(0, 0, 0, 0.02);
+		border-left: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-right: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-top: 2rpx solid rgba(0, 0, 0, 0.04);
+		border-bottom: 2rpx solid rgba(0, 0, 0, 0.04);
+		padding-left: 18rpx;
+		padding-right: 18rpx;
+		padding-top: 18rpx;
+		padding-bottom: 18rpx;
+		border-radius: 18rpx;
+	}
+		.hl88-lr-shop-list-item-active {
+			background: rgba(62, 214, 86, 0.12);
+			border-left: 2rpx solid rgba(62, 214, 86, 0.45);
+			border-right: 2rpx solid rgba(62, 214, 86, 0.45);
+			border-top: 2rpx solid rgba(62, 214, 86, 0.45);
+			border-bottom: 2rpx solid rgba(62, 214, 86, 0.45);
+		}
+		.hl88-lr-shop-list-item-img {
+			width: 100rpx;
+			height: 100rpx;
+		}
+			.hl88-lr-shop-list-item-image {
+				width: 100rpx;
+				height: 100rpx;
+				border-radius: 5000rpx;
+			}
+		.hl88-lr-shop-list-title{
+			flex: 1;
+			margin-left: 18rpx;
+		}
+		.hl88-lr-shop-list-item-title {
+			flex: 1;
+			margin-left: 18rpx;
+			margin-right: 18rpx;
+		}
+			.hl88-lr-shop-list-item-title-text {
+				font-size: 28rpx;
+				color: rgba(0, 0, 0, 0.7);
+			}
+	
+	/* 付费设置 */
+	.hl88-lr-payment-setting-item {
+		margin-bottom: 30rpx;
+		flex-direction: column;
+	}
+		.hl88-lr-payment-setting-label {
+			font-size: 28rpx;
+			color: #333;
+			margin-bottom: 18rpx;
+		}
+		.hl88-lr-payment-switch {
+			flex-direction: row;
+			border-left: 2rpx solid rgba(0, 0, 0, 0.1);
+			border-right: 2rpx solid rgba(0, 0, 0, 0.1);
+			border-top: 2rpx solid rgba(0, 0, 0, 0.1);
+			border-bottom: 2rpx solid rgba(0, 0, 0, 0.1);
+			border-radius: 18rpx;
+			overflow: hidden;
+			background: #f5f5f5;
+		}
+			.hl88-lr-payment-switch-item {
+				flex: 1;
+				height: 76rpx;
+				align-items: center;
+				justify-content: center;
+				background: transparent;
+			}
+			.hl88-lr-payment-switch-item-active {
+				background: #3ed656;
+			}
+				.hl88-lr-payment-switch-text {
+					font-size: 28rpx;
+					color: #666;
+				}
+				.hl88-lr-payment-switch-text-active {
+					color: #fff !important;
+					font-weight: bold;
+				}
+		/* 付费输入框容器(滑动动画) */
+		.hl88-lr-payment-input-container {
+			/* #ifndef APP-NVUE */
+			max-height: 0;
+			/* #endif */
+			opacity: 0;
+			overflow: hidden;
+			margin-bottom: 0;
+			/* #ifndef APP-NVUE */
+			transition: all 0.3s ease-in-out;
+			/* #endif */
+		}
+		.hl88-lr-payment-input-container-show {
+			/* #ifndef APP-NVUE */
+			max-height: 300rpx;
+			overflow: visible;
+			/* #endif */
+			opacity: 1;
+			margin-bottom: 30rpx;
+		}
+		.hl88-lr-payment-input {
+			height: 76rpx;
+			border-left: 2rpx solid rgba(0, 0, 0, 0.04);
+			border-right: 2rpx solid rgba(0, 0, 0, 0.04);
+			border-top: 2rpx solid rgba(0, 0, 0, 0.04);
+			border-bottom: 2rpx solid rgba(0, 0, 0, 0.04);
+			border-radius: 18rpx;
+			padding-left: 18rpx;
+			padding-right: 18rpx;
+			font-size: 28rpx;
+			color: #333;
+			}
+
+/* 开播准备弹窗(nvue webview 兼容 flex 布局) */
+.hl88-lr-popup--start {
+	position: relative;
+	width: 750rpx;
+	background-color: #f5f7fb;
+	padding-left: 28rpx;
+	padding-right: 28rpx;
+	border-top-left-radius: 28rpx;
+	border-top-right-radius: 28rpx;
+}
+.hl88-lr-start-hero {
+	width: 694rpx;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	margin-top: 24rpx;
+	margin-bottom: 20rpx;
+	padding-top: 28rpx;
+	padding-bottom: 28rpx;
+	padding-left: 24rpx;
+	padding-right: 24rpx;
+	border-radius: 28rpx;
+	background-color: #4a8cff;
+}
+.hl88-lr-start-hero-main {
+	flex: 1;
+	flex-direction: column;
+	padding-right: 16rpx;
+}
+.hl88-lr-start-hero-badge {
+	font-size: 22rpx;
+	color: #4a8cff;
+	background-color: rgba(255, 255, 255, 0.92);
+	font-weight: 600;
+	padding-top: 6rpx;
+	padding-bottom: 6rpx;
+	padding-left: 18rpx;
+	padding-right: 18rpx;
+	border-radius: 999rpx;
+	margin-bottom: 12rpx;
+	align-self: flex-start;
+}
+.hl88-lr-start-hero-title {
+	font-size: 36rpx;
+	font-weight: 700;
+	color: #ffffff;
+	line-height: 48rpx;
+}
+.hl88-lr-start-hero-close {
+	width: 56rpx;
+	height: 56rpx;
+	border-radius: 28rpx;
+	background-color: rgba(255, 255, 255, 0.18);
+	align-items: center;
+	justify-content: center;
+	flex-shrink: 0;
+}
+.hl88-lr-start-hero-close-img {
+	width: 28rpx;
+	height: 28rpx;
+}
+.hl88-lr-start-scroll {
+	width: 750rpx;
+}
+.hl88-lr-start-body {
+	width: 750rpx;
+	padding-bottom: 24rpx;
+}
+.hl88-lr-start-card {
+	width: 694rpx;
+	margin-bottom: 20rpx;
+	padding-top: 24rpx;
+	padding-bottom: 24rpx;
+	padding-left: 24rpx;
+	padding-right: 24rpx;
+	background-color: #ffffff;
+	border-radius: 28rpx;
+	border-width: 1rpx;
+	border-color: rgba(74, 140, 255, 0.12);
+	border-style: solid;
+}
+.hl88-lr-start-card-hd {
+	flex-direction: row;
+	align-items: center;
+	margin-bottom: 20rpx;
+}
+.hl88-lr-start-card-icon {
+	width: 64rpx;
+	height: 64rpx;
+	border-radius: 18rpx;
+	background-color: rgba(74, 140, 255, 0.12);
+	font-size: 32rpx;
+	text-align: center;
+	line-height: 64rpx;
+	margin-right: 16rpx;
+	flex-shrink: 0;
+}
+.hl88-lr-start-card-meta {
+	flex: 1;
+	flex-direction: column;
+}
+.hl88-lr-start-card-title {
+	font-size: 30rpx;
+	font-weight: 700;
+	color: #1a1d26;
+	line-height: 40rpx;
+}
+.hl88-lr-start-card-hint {
+	margin-top: 6rpx;
+	font-size: 24rpx;
+	color: #8a8d9e;
+	line-height: 34rpx;
+}
+.hl88-lr-start-cover-wrap {
+	width: 646rpx;
+	height: 364rpx;
+	border-radius: 20rpx;
+	overflow: hidden;
+	border-width: 2rpx;
+	border-style: dashed;
+	border-color: rgba(74, 140, 255, 0.35);
+	background-color: #f5f8ff;
+}
+.hl88-lr-start-cover-img {
+	width: 646rpx;
+	height: 364rpx;
+	border-radius: 18rpx;
+}
+.hl88-lr-start-segment {
+	width: 646rpx;
+	flex-direction: row;
+	align-items: center;
+	padding-top: 8rpx;
+	padding-bottom: 8rpx;
+	padding-left: 8rpx;
+	padding-right: 8rpx;
+	border-radius: 999rpx;
+	background-color: #f5f7fb;
+}
+.hl88-lr-start-segment-item {
+	flex: 1;
+	height: 72rpx;
+	border-radius: 999rpx;
+	align-items: center;
+	justify-content: center;
+}
+.hl88-lr-start-segment-item--active {
+	background-color: #4a8cff;
+}
+.hl88-lr-start-segment-text {
+	font-size: 28rpx;
+	font-weight: 600;
+	color: #8a8d9e;
+}
+.hl88-lr-start-segment-text--active {
+	color: #ffffff;
+}
+.hl88-lr-start-field {
+	margin-top: 20rpx;
+	flex-direction: column;
+}
+.hl88-lr-start-field-label {
+	margin-bottom: 12rpx;
+	font-size: 26rpx;
+	color: #5a6b8a;
+	line-height: 36rpx;
+}
+.hl88-lr-start-field-input {
+	height: 76rpx;
+	padding-left: 20rpx;
+	padding-right: 20rpx;
+	border-radius: 16rpx;
+	background-color: #f5f7fb;
+	font-size: 28rpx;
+	color: #1a1d26;
+}
+.hl88-lr-start-theme-input {
+	width: 100%;
+	height: 120rpx;
+	padding-top: 20rpx;
+	padding-bottom: 20rpx;
+	padding-left: 20rpx;
+	padding-right: 20rpx;
+	border-radius: 16rpx;
+	background-color: #f5f7fb;
+	font-size: 28rpx;
+	line-height: 44rpx;
+	color: #1a1d26;
+}
+.hl88-lr-start-field-ph {
+	color: #b0b4c0;
+	font-size: 28rpx;
+}
+.hl88-lr-start-theme-count {
+	flex-shrink: 0;
+	font-size: 24rpx;
+	color: #b0b4c0;
+	line-height: 34rpx;
+}
+.hl88-lr-start-foot {
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	width: 750rpx;
+	padding-top: 12rpx;
+	padding-left: 28rpx;
+	padding-right: 28rpx;
+	background-color: #ffffff;
+	border-top-width: 1rpx;
+	border-top-color: rgba(74, 140, 255, 0.12);
+	border-top-style: solid;
+}
+.hl88-lr-start-submit {
+	width: 694rpx;
+	height: 88rpx;
+	border-radius: 999rpx;
+	align-items: center;
+	justify-content: center;
+	background-color: #4a8cff;
+	background-image: linear-gradient(135deg, #4a8cff 0%, #6ec8ff 100%);
+}
+.hl88-lr-start-submit-text {
+	font-size: 30rpx;
+	font-weight: 700;
+	color: #ffffff;
+}
+</style>

+ 44 - 0
components/live-room/empty.vue

@@ -0,0 +1,44 @@
+<template>
+	<view class="hl88-lr-empty">
+		<view class="hl88-lr-empty-tag">
+			<text class="hl88-lr-empty-text">{{text}}</text>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: 'LiveRoomEmpty',
+		props: {
+			text: {
+				type: String,
+				default: ''
+			}
+		}
+	}
+</script>
+
+<style>
+	.hl88-lr-empty{
+		position: absolute;
+		left: 0;
+		right: 0;
+		top: 0;
+		bottom: 0;
+		align-items: center;
+		justify-content: center;
+	}
+	.hl88-lr-empty-tag{
+		background-color: rgba(255,100,0,.3);
+		height: 70rpx;
+		padding-left: 20rpx;
+		padding-right: 20rpx;
+		align-items: center;
+		justify-content: center;
+		border-radius: 100px;
+	}
+	.hl88-lr-empty-text{
+		font-size: 32rpx;
+		color: #ffffff;
+	}
+</style>

+ 413 - 0
components/live-room/footer.vue

@@ -0,0 +1,413 @@
+
+<template>
+	<view class="hl88-lr-footer">
+		<view class="hl88-lr-footer-bottom_msg">
+			<input type="text" 
+				v-if="state == 1"
+				class="hl88-lr-footer-bottom_msg_input" 
+				confirm-type="send" 
+				v-model="message" 
+				@confirm="handleSend('msg')"
+				placeholder-style="rgba(255, 255, 255, .8)"
+				placeholder-class="hl88-lr-footer-bottom_msg_input-placeholder" 
+				placeholder="Say something~" 
+			/>
+		</view>
+		<!-- H5 页面需要发送按钮 -->
+		<!-- #ifndef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || APP-PLUS -->
+		<view class="hl88-lr-footer-bottom_send" v-if="state == 1">
+			<view class="hl88-lr-footer-bottom_send-btn" @tap="handleSend('msg')">
+				<text class="hl88-lr-footer-bottom_send-btn-text">Send</text>
+			</view>
+		</view>
+		<!-- #endif -->
+		<!-- 购物袋 -->
+		<!-- <view class="hl88-lr-footer-bottom_controll"> </view> -->
+		<!-- <view class="hl88-lr-footer-bottom_goods" @tap="handlePopup('open')">
+			<view class="hl88-lr-btn-icon hl88-lr-btn-ghost">
+				
+				<image class="hl88-lr-btn-icon-img" src="/static/images/live/shop.png"/>
+				<view class="hl88-lr-btn-badge" v-if="goods.length != 0">
+					<text class="hl88-lr-btn-badge-text">{{numFormat(goods.length)}}</text>
+				</view>
+			</view>
+		</view> -->
+		<!-- 发送点赞 -->
+		<view class="hl88-lr-footer-bottom_praise" @tap="handleSend('like')" v-if="state == 1">
+			<view class="hl88-lr-btn-icon">
+				<text class="hl88-lr-btn-icon-text hl88-lr-footer-bottom_praise-btn-text">&#xe635;</text>
+				<view class="hl88-lr-btn-badge" v-if="like != 0">
+					<text class="hl88-lr-btn-badge-text">{{numFormat(like)}}</text>
+				</view>
+			</view>
+		</view>
+		<!-- 弹出窗口 -->
+		<uni-popup ref="goods" type="bottom">
+			<view class="hl88-lr-popup">
+				<view class="hl88-lr-popup-title">
+					<text class="hl88-lr-popup-title-text"> {{goods.length}} Item</text>
+					<text class="hl88-lr-popup-title-close-text" @tap="handlePopup('close')">&#xe66b;</text>
+				</view>
+				<scroll-view scroll-y="true" class="hl88-lr-popup-list">
+					<view class="hl88-lr-popup-list-item" v-for="(item, index) in goods" :key="item.id">
+						<view class="hl88-lr-popup-list-item-img">
+							<image :src="stcOss(item.image)" class="hl88-lr-popup-list-item-image"></image>
+						</view>
+						<view class="hl88-lr-rank-tag" :class="['tagbg_' + (index + 1)]">
+							<text class="hl88-lr-rank-tag-text">{{index + 1}}</text>
+						</view>
+						<view class="hl88-lr-popup-list-item-subject">
+							<view class="hl88-lr-popup-list-item-subject-title">
+								<text class="hl88-lr-popup-list-item-subject-title-text">{{item.title}}</text>
+							</view>
+							<view class="hl88-lr-popup-list-item-subject-operation">
+								<view class="hl88-lr-popup-list-item-subject-operation-price">
+									<text class="hl88-lr-popup-list-item-subject-operation-price-text">RM{{item.price}}</text>
+								</view>
+								<view class="hl88-lr-popup-list-item-subject-operation-button">
+									<view class="hl88-lr-popup-list-item-subject-operation-button-seek" @tap="handleSend('seek',index + 1)" v-if="state == 1">
+										<text class="hl88-lr-popup-list-item-subject-operation-button-seek-text">explain</text>
+									</view>
+									<view class="hl88-lr-popup-list-item-subject-operation-button-shopping" @tap="handleGoods(item.id)">
+										<text class="hl88-lr-popup-list-item-subject-operation-button-shopping-text">&#xe636;</text>
+									</view>
+								</view>
+							</view>
+						</view>
+					</view>
+				</scroll-view>
+				<!-- 多终端兼容性 -->
+				<view :style="{height: statusFootHeight + 'px'}"></view>
+			</view>
+		</uni-popup>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'LiveRoomFooter',
+	props: {
+		statusFootHeight: {
+			default: 0
+		},
+		like:{
+			type: Number,
+			default: 0
+		},
+		state:{
+			default: 0
+		},
+		goods: {
+			type: Array,
+			default: () => []
+		}
+	},
+	beforeCreate() {
+		// #ifdef APP-NVUE
+		var domModule = weex.requireModule('dom');
+		domModule.addRule('fontFace', {
+			fontFamily: 'iconfont',
+			src: "url('/static/style/iconfont.ttf')"
+		});
+		// #endif
+	},
+	data() {
+		return {
+			message: ''
+		};
+	},
+	methods: {
+		// 操作弹出框
+		handlePopup(method) {
+			this.$refs.goods[method]();
+		},
+		// 传递给live 消息
+		handleSend(type, index){
+			switch(type) {
+				// 发送消息
+				case 'msg': 
+					if (this.message) {
+						this.$emit('change',{
+							type: 'msg',
+							message: this.message
+						});
+						this.message = '';
+					}
+					// 收起键盘
+					uni.hideKeyboard();
+					break;
+				// 点赞
+				case 'like':
+					this.$emit('change',{type: 'like'});
+					break;
+				// 求讲解
+				case 'seek':
+					this.$emit('change',{
+						type: 'seek',
+						key: index
+					});
+					this.handlePopup('close');
+					break;
+			} 
+		},
+		
+		handleGoods(id){
+			uni.navigateTo({
+				url: `/pages/product/goods?id=${id}`
+			})
+			this.handlePopup('close');
+		},
+		stcOss(url){
+				let oss = (this.$store.state.shell && this.$store.state.shell.appUrl && this.$store.state.shell.appUrl.oss) || '';
+			let image = '';
+			if (url) {
+				if((/^(http|https):\/\/.+/.test(url))){
+					image = url;
+				}else{
+					image = oss + url;
+				}
+			}else{
+				image = oss + '/assets/addons/wanlshop/img/common/img_default3x.png';
+			}
+			return image;
+		},
+		numFormat(num){
+			return num > 9999 ? ((num-num%1000)/10000 + 'w') : num
+		}
+	}
+};
+</script>
+<style>
+@import '@/static/style/wanlnvue.css';
+.hl88-lr-footer {
+	align-items: center;
+	justify-content: space-between;
+	flex-direction: row;
+	padding-top: 18rpx;
+	padding-bottom: 18rpx;
+}
+	/* 消息按钮 */
+	.hl88-lr-footer-bottom_msg {
+		/* #ifndef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || APP-PLUS */
+		flex: 3;
+		/* #endif */
+		/* #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || APP-PLUS */
+		flex: 4;
+		/* #endif */
+		margin-left: 25rpx;
+		margin-right: 25rpx;
+	}
+		.hl88-lr-footer-bottom_msg_input{
+			height: 76rpx;
+			background-color: rgba(0,0,0,.2);
+			border-radius: 100px;
+			color: #fff;
+			padding-left: 25rpx;
+			padding-right: 25rpx;
+			font-size: 28rpx;
+		}
+		.hl88-lr-footer-bottom_msg_input-placeholder{
+			color: #FFFFFF;
+		}
+	/* 发送按钮 */
+	.hl88-lr-footer-bottom_send {
+		flex: 1;
+		align-items: flex-start;
+	}
+		.hl88-lr-footer-bottom_send-btn{
+			background-color: #f02b57;
+			border-radius: 100px;
+			height: 50rpx;
+			width: 100rpx;
+			justify-content: center;
+			align-items: center;
+		}
+		.hl88-lr-footer-bottom_send-btn-text{
+			color: #ffffff;
+			font-size: 28rpx;
+		}
+		
+		
+	/* 进度条 */
+	.hl88-lr-footer-bottom_controll {
+		justify-content: center;
+		align-items: center;
+		color: #fff;
+		flex: 4;
+		z-index: 100;
+		position: relative;
+		height: 76rpx;
+		line-height: 76rpx;
+	}
+		.hl88-lr-footer-bottom_controll-play{
+			width: 76rpx;
+			height: 76rpx;
+			position: relative;
+		}
+	/* 商品 */
+	.hl88-lr-footer-bottom_goods {
+		position: relative;
+		flex: 1;
+		justify-content: center;
+		align-items: center;
+	}
+		
+		/* 购物袋颜色 */
+		.hl88-lr-footer-bottom_goods-btn-text{
+			color: #f40;
+			font-size: 56rpx;
+		}
+		
+	/* 点赞 */
+	.hl88-lr-footer-bottom_praise {
+		position: relative;
+		justify-content: center;
+		align-items: center;
+		flex: 1;
+	}
+		.hl88-lr-footer-bottom_praise-btn-text{
+			color: #fff;
+			font-size: 41rpx;
+		}
+	/* POPUP */
+	.hl88-lr-popup{
+		position: relative;
+		background: rgba(255, 255, 255, 0.93);
+		padding-left: 18rpx;
+		padding-right: 18rpx;
+		border-top-left-radius: 18rpx;
+		border-top-right-radius: 18rpx;
+	}
+		.hl88-lr-popup-title {
+			height: 100rpx;
+			flex-direction: row;
+			align-items: center;
+			justify-content: space-between;
+		}
+			.hl88-lr-popup-title-text {
+				font-size: 32rpx;
+			}
+			.hl88-lr-popup-title-close-text{
+				font-size: 32rpx;
+				/* #ifndef APP-PLUS */
+				font-family: "iconfont";
+				/* #endif*/
+				font-family: iconfont;
+				width: 40rpx;
+				height: 40rpx;
+			}
+		
+	.hl88-lr-popup-list{
+		/* #ifdef APP-PLUS */
+		height: 690rpx;
+		/* #endif */
+		
+		/* #ifdef H5 || MP */
+		max-height: 1000rpx;
+		min-height: 200rpx;
+		/* #endif */
+	}	
+		.hl88-lr-popup-list-item{
+			position: relative;
+			flex-direction: row;
+			background: #fff;
+			padding-left: 18rpx;
+			padding-right: 18rpx;
+			padding-top: 18rpx;
+			padding-bottom: 18rpx;
+			margin-bottom: 18rpx;
+			border-radius: 18rpx
+		}
+			
+			.hl88-lr-popup-list-item-img{
+				width: 180rpx;
+				height: 180rpx;
+				border-top-left-radius: 12rpx;
+				border-top-right-radius: 12rpx;
+				border-bottom-right-radius: 12rpx;
+				border-bottom-left-radius: 12rpx;
+				overflow: hidden;
+				background-color: #f7f7f7;
+				margin-right: 20rpx;
+			}
+				.hl88-lr-popup-list-item-image{
+					width: 180rpx;
+					height: 180rpx;
+					border-radius: 18rpx;
+				}
+				
+			.hl88-lr-popup-list-item-subject{
+				flex: 1;
+				justify-content: space-between;
+			}
+				
+				.hl88-lr-popup-list-item-subject-title{
+					/* #ifdef MP || H5 */
+					width: 100%;
+					overflow: hidden;
+					text-overflow: ellipsis;
+					display: -webkit-box;
+					-webkit-box-orient: vertical;
+					-webkit-line-clamp: 2;
+					/* #endif */
+				}
+				.hl88-lr-popup-list-item-subject-title-text{
+					color: #222222;
+					font-size: 28rpx;
+					lines:2;
+					text-overflow:ellipsis;
+					/* #ifdef APP-PLUS */
+					line-height: 36rpx;
+					/* #endif */
+				}
+				
+				.hl88-lr-popup-list-item-subject-operation{
+					align-items: center;
+					justify-content: space-between;
+					flex-direction: row;
+				}
+				.hl88-lr-popup-list-item-subject-operation-price{
+					
+				}
+					.hl88-lr-popup-list-item-subject-operation-price-text{
+						color: #f72b36;
+						font-size: 32rpx;
+						font-weight: 500;
+					}
+				.hl88-lr-popup-list-item-subject-operation-button{
+					flex-direction: row;
+					
+				}
+					.hl88-lr-popup-list-item-subject-operation-button-seek{
+						margin-right: 18rpx;
+						border-radius: 100px;
+						justify-content: center;
+						align-items: center;
+						height: 54rpx;
+						width: 130rpx;
+						border-left: 2rpx solid #6200ff;
+						border-right: 2rpx solid #6200ff;
+						border-top: 2rpx solid #6200ff;
+						border-bottom: 2rpx solid #6200ff;
+					}
+						.hl88-lr-popup-list-item-subject-operation-button-seek-text{
+							color: #6200ff;
+							font-size: 27rpx;
+						}
+					.hl88-lr-popup-list-item-subject-operation-button-shopping{
+						background-color: #f72b36;
+						border-radius: 100px;
+						justify-content: center;
+						align-items: center;
+						width: 54rpx;
+						height: 54rpx;
+					}
+						.hl88-lr-popup-list-item-subject-operation-button-shopping-text{
+							font-size: 30rpx;
+							color: #fff;
+							/* #ifndef APP-PLUS */
+							font-family: "iconfont";
+							/* #endif*/
+							font-family: iconfont;
+						}
+</style>

+ 91 - 0
components/live-room/gift-animation-h5.js

@@ -0,0 +1,91 @@
+/**
+ * H5 专用的 SVGA 初始化代码
+ * 使用 CDN 方式加载 SVGA,避免 webpack 解析问题
+ */
+
+// 加载 SVGA 库(使用 CDN 方式,避免 webpack 解析问题)
+function loadSVGALibrary() {
+	return new Promise((resolve, reject) => {
+		// 检查是否已经加载
+		if (window.SVGAPlayer && window.SVGAPlayer.Parser && window.SVGAPlayer.Player) {
+			resolve(window.SVGAPlayer)
+			return
+		}
+		
+		// 检查是否正在加载
+		if (window.__SVGA_LOADING__) {
+			window.__SVGA_LOADING__.then(resolve).catch(reject)
+			return
+		}
+		
+		// 开始从 CDN 加载(使用动态导入方式)
+		window.__SVGA_LOADING__ = (async () => {
+			try {
+				// 使用 Function 构造函数动态导入,完全避免 webpack 静态分析
+				const dynamicImport = new Function('url', 'return import(url)')
+				const svgaModule = await dynamicImport('https://unpkg.com/svga@2.5.7/dist/index.esm.js')
+				if (svgaModule && svgaModule.Parser && svgaModule.Player) {
+					return svgaModule
+				}
+				throw new Error('SVGA module structure not recognized')
+			} catch (error) {
+				throw new Error('Failed to load SVGA library from CDN: ' + error.message)
+			}
+		})()
+		
+		window.__SVGA_LOADING__.then(resolve).catch(reject)
+	})
+}
+
+export async function initSVGA(canvasId, giftType, onEnd) {
+	try {
+		// 加载 SVGA 库(CDN 方式)
+		const SVGA = await loadSVGALibrary()
+		const { Parser, Player } = SVGA
+		const parser = new Parser()
+		
+		// 根据礼物类型加载对应的 SVGA 文件
+		const svgaUrl = getSVGAUrl(giftType)
+		if (!svgaUrl) {
+			console.warn('SVGA file not found for gift type:', giftType)
+			return null
+		}
+		
+		const svga = await parser.load(svgaUrl)
+		const canvas = document.getElementById(canvasId)
+		if (!canvas) return null
+		
+		const player = new Player(canvas)
+		await player.mount(svga)
+		
+		// 监听动画事件
+		player.onEnd = onEnd
+		
+		return { parser, player }
+	} catch (error) {
+		console.error('SVGA init error:', error)
+		return null
+	}
+}
+
+export function getSVGAUrl(giftType) {
+	// 根据礼物类型返回对应的 SVGA 文件路径
+	const svgaMap = {
+		'rose': '/static/gifts/rose.svga',
+		'heart': '/static/gifts/heart.svga',
+		'star': '/static/gifts/star.svga',
+		'default': '/static/gifts/default.svga'
+	}
+	return svgaMap[giftType] || svgaMap['default']
+}
+
+export function destroySVGA(parser, player) {
+	if (player) {
+		player.clear()
+		player.destroy()
+	}
+	if (parser) {
+		parser.destroy()
+	}
+}
+

+ 583 - 0
components/live-room/gift-animation.vue

@@ -0,0 +1,583 @@
+<!-- 
+	打赏动画组件
+	支持 SVGA (H5) 和 CSS 动画 (APP)
+-->
+<template>
+	<!-- H5 端使用 SVGA -->
+	<!-- #ifdef H5 -->
+	<view class="hl88-lr-ga-wrap" v-if="show">
+		<canvas 
+			:id="canvasId" 
+			class="hl88-lr-ga-canvas"
+			:style="{ width: width + 'px', height: height + 'px' }"
+		></canvas>
+	</view>
+	<!-- #endif -->
+	
+	<!-- APP 端使用 CSS 动画 (非 nvue) -->
+	<!-- #ifdef APP-PLUS -->
+	<!-- #ifndef APP-NVUE -->
+	<view class="hl88-lr-ga-wrap" v-if="show">
+		<view class="hl88-lr-ga-app" :class="giftClass">
+			<image 
+				:src="giftImage" 
+				class="hl88-lr-ga-img"
+				mode="aspectFit"
+			></image>
+			<view class="hl88-lr-ga-txt" v-if="giftText">{{ giftText }}</view>
+		</view>
+	</view>
+	<!-- #endif -->
+	<!-- #endif -->
+	
+	<!-- nvue 端使用动画 -->
+	<!-- #ifdef APP-NVUE -->
+	<view class="hl88-lr-ga-wrapNv" v-if="show">
+		<view 
+			class="hl88-lr-ga-boxNv" 
+			:ref="'giftView'"
+		>
+			<image 
+				:src="giftImage" 
+				class="hl88-lr-ga-imgNv"
+				mode="aspectFit"
+				:ref="'giftImage'"
+			></image>
+			<text class="hl88-lr-ga-txtNv" v-if="giftText" :ref="'giftText'">{{ giftText }}</text>
+		</view>
+	</view>
+	<!-- #endif -->
+	
+	<!-- 小程序端使用简单动画 -->
+	<!-- #ifdef MP -->
+	<view class="hl88-lr-ga-wrap" v-if="show">
+		<view class="hl88-lr-ga-mp" :class="giftClass">
+			<image 
+				:src="giftImage" 
+				class="hl88-lr-ga-img"
+				mode="aspectFit"
+			></image>
+			<view class="hl88-lr-ga-txt" v-if="giftText">{{ giftText }}</view>
+		</view>
+	</view>
+	<!-- #endif -->
+</template>
+
+<script>
+export default {
+	name: 'LiveRoomGiftAnimation',
+	props: {
+		// 礼物类型
+		giftType: {
+			type: String,
+			default: 'default'
+		},
+		// 礼物图片
+		giftImage: {
+			type: String,
+			default: ''
+		},
+		// 礼物文字
+		giftText: {
+			type: String,
+			default: ''
+		},
+		// 动画宽度
+		width: {
+			type: Number,
+			default: 300
+		},
+		// 动画高度
+		height: {
+			type: Number,
+			default: 300
+		}
+	},
+	data() {
+		return {
+			show: false,
+			canvasId: 'hl88-lr-ga-canvas-' + Date.now(),
+			giftClass: '',
+			// #ifdef H5
+			parser: null,
+			player: null,
+			// #endif
+			// #ifdef APP-NVUE
+			animationType: 'zoomIn', // 动画类型: 从远到近
+			animationTimer: null
+			// #endif
+		}
+	},
+	mounted() {
+		// #ifdef H5
+		// 只在 H5 环境下初始化 SVGA
+		if (typeof window !== 'undefined' && typeof document !== 'undefined') {
+			this.initSVGA()
+		}
+		// #endif
+	},
+	beforeDestroy() {
+		// #ifdef H5
+		this.destroySVGA()
+		// #endif
+	},
+	methods: {
+		// #ifdef H5
+		async initSVGA() {
+			// 只在 H5 环境下执行
+			if (typeof window === 'undefined' || typeof document === 'undefined') {
+				return
+			}
+			try {
+				// 动态加载 H5 专用模块
+				const h5Module = await import('./gift-animation-h5.js')
+				const result = await h5Module.initSVGA(this.canvasId, this.giftType, () => {
+					this.hide()
+				})
+				if (result) {
+					this.parser = result.parser
+					this.player = result.player
+				}
+			} catch (error) {
+				console.warn('SVGA module not available:', error)
+				// 如果 SVGA 模块不可用,使用简单的显示方式
+				setTimeout(() => {
+					this.hide()
+				}, 2000)
+			}
+		},
+		
+		destroySVGA() {
+			if (this.parser || this.player) {
+				try {
+					// 动态加载销毁函数
+					import('./gift-animation-h5.js').then(h5Module => {
+						h5Module.destroySVGA(this.parser, this.player)
+					}).catch(() => {
+						// 如果模块不可用,直接清理引用
+						if (this.player) {
+							this.player = null
+						}
+						if (this.parser) {
+							this.parser = null
+						}
+					})
+				} catch (error) {
+					// 直接清理引用
+					this.parser = null
+					this.player = null
+				}
+			}
+		},
+		// #endif
+		
+		// 显示动画
+		play() {
+			this.show = true
+			this.giftClass = 'hl88-lr-ga-play'
+			
+			// #ifdef H5
+			if (this.player) {
+				this.player.start()
+			}
+			// #endif
+			
+			// #ifndef H5
+			// APP 和小程序端,动画结束后自动隐藏
+			// #ifdef APP-NVUE
+			// nvue 端使用 animation 模块实现动画
+			this.$nextTick(() => {
+				this.playNvueAnimation()
+			})
+			// #endif
+			// #ifndef APP-NVUE
+			// 非 nvue 的 APP 端可以使用 CSS 动画
+			setTimeout(() => {
+				this.hide()
+			}, 3000)
+			// #endif
+			// #endif
+	},
+		
+		// 隐藏动画
+		hide() {
+			// #ifdef APP-NVUE
+			// 清理动画定时器
+			if (this.animationTimer) {
+				clearTimeout(this.animationTimer)
+				this.animationTimer = null
+			}
+			// #endif
+			this.show = false
+			this.giftClass = ''
+			this.$emit('end')
+		},
+		
+		// #ifdef APP-NVUE
+		// nvue 端动画实现 - 使用从远到近的3D透视效果
+		playNvueAnimation() {
+			// 清理之前的动画定时器
+			if (this.animationTimer) {
+				clearTimeout(this.animationTimer)
+			}
+			
+			// 获取 animation 模块
+			let animation = null
+			try {
+				animation = uni.requireNativePlugin('animation')
+			} catch (e) {
+				// 兼容旧版本
+				try {
+					animation = weex.requireModule('animation')
+				} catch (e2) {
+					console.warn('Animation module not available')
+				}
+			}
+			
+			const giftView = this.$refs.giftView
+			if (!animation || !giftView) {
+				// 降级方案:使用简单的延时
+				this.animationTimer = setTimeout(() => {
+					this.hide()
+				}, 2500)
+				return
+			}
+			
+			// 使用从远到近的3D透视动画
+			this.playZoomInAnimation(animation, giftView)
+		},
+		
+		// 从远到近的3D透视动画
+		playZoomInAnimation(animation, giftView) {
+			// nvue 中需要获取真实的 ref
+			let ref = giftView
+			if (giftView && giftView.ref) {
+				ref = giftView.ref
+			} else if (this.$refs.giftView && this.$refs.giftView.ref) {
+				ref = this.$refs.giftView.ref
+			}
+			
+			// 阶段1:初始状态 - 在远处(很小、透明、稍微偏上)
+			animation.transition(ref, {
+				styles: {
+					opacity: 0,
+					transform: 'scale(0.1) translateY(0px)',
+					transformOrigin: 'center center'
+				},
+				duration: 0,
+				timingFunction: 'ease-out',
+				delay: 0
+			})
+			
+			// 阶段2:快速靠近并淡入(0.6秒)- 从远处快速放大
+			setTimeout(() => {
+				animation.transition(ref, {
+					styles: {
+						opacity: 1,
+						transform: 'scale(1.15) translateY(-20px)',
+						transformOrigin: 'center center'
+					},
+					duration: 600,
+					timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
+					delay: 0
+				})
+			}, 30)
+			
+			// 阶段3:轻微回弹到正常大小(0.25秒)- 模拟惯性
+			setTimeout(() => {
+				animation.transition(ref, {
+					styles: {
+						opacity: 1,
+						transform: 'scale(0.98) translateY(-10px)',
+						transformOrigin: 'center center'
+					},
+					duration: 250,
+					timingFunction: 'ease-out',
+					delay: 0
+				})
+			}, 630)
+			
+			// 阶段4:稳定到正常大小并上浮(0.15秒)
+			setTimeout(() => {
+				animation.transition(ref, {
+					styles: {
+						opacity: 1,
+						transform: 'scale(1) translateY(-30px)',
+						transformOrigin: 'center center'
+					},
+					duration: 150,
+					timingFunction: 'ease-out',
+					delay: 0
+				})
+			}, 880)
+			
+			// 阶段5:保持并轻微波动(1.2秒)- 营造生动感
+			setTimeout(() => {
+				animation.transition(ref, {
+					styles: {
+						opacity: 1,
+						transform: 'scale(1.05) translateY(-35px)',
+						transformOrigin: 'center center'
+					},
+					duration: 600,
+					timingFunction: 'ease-in-out',
+					delay: 0
+				}, () => {
+					// 回调:再次波动
+					setTimeout(() => {
+						animation.transition(ref, {
+							styles: {
+								opacity: 1,
+								transform: 'scale(1) translateY(-30px)',
+								transformOrigin: 'center center'
+							},
+							duration: 600,
+							timingFunction: 'ease-in-out',
+							delay: 0
+						})
+					}, 50)
+				})
+			}, 1030)
+			
+			// 阶段6:淡出并继续缩小远去(0.6秒)
+			setTimeout(() => {
+				animation.transition(ref, {
+					styles: {
+						opacity: 0,
+						transform: 'scale(0.7) translateY(-60px)',
+						transformOrigin: 'center center'
+					},
+					duration: 600,
+					timingFunction: 'ease-in',
+					delay: 0
+				}, () => {
+					this.hide()
+				})
+			}, 2280)
+		},
+		
+		
+		// #endif
+	}
+}
+</script>
+
+<style scoped>
+/* #ifndef APP-NVUE */
+.hl88-lr-ga-wrap {
+	position: fixed;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+	z-index: 1000;
+	pointer-events: none;
+}
+/* #endif */
+
+/* nvue 端样式 */
+/* #ifdef APP-NVUE */
+.hl88-lr-ga-wrapNv {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 1000;
+	justify-content: center;
+	align-items: center;
+}
+
+.hl88-lr-ga-boxNv {
+	width: 300px;
+	height: 300px;
+	justify-content: center;
+	align-items: center;
+}
+
+.hl88-lr-ga-imgNv {
+	width: 200px;
+	height: 200px;
+}
+
+.hl88-lr-ga-txtNv {
+	position: absolute;
+	bottom: 20px;
+	color: #ffffff;
+	font-size: 24px;
+	font-weight: bold;
+}
+/* #endif */
+
+/* H5 SVGA Canvas */
+/* #ifdef H5 */
+.hl88-lr-ga-canvas {
+	display: block;
+}
+/* #endif */
+
+/* APP 端 CSS 动画 (非 nvue) */
+/* #ifdef APP-PLUS */
+.hl88-lr-ga-app {
+	position: relative;
+	width: 300px;
+	height: 300px;
+	justify-content: center;
+	align-items: center;
+	opacity: 0;
+	transform: scale(0.3);
+}
+
+.hl88-lr-ga-app.hl88-lr-ga-play {
+	animation: hl88-lr-ga-zoomIn 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards,
+	           hl88-lr-ga-float 1.6s ease-in-out 0.8s infinite,
+	           hl88-lr-ga-fadeOut 0.6s ease-in 2.4s forwards;
+}
+
+.hl88-lr-ga-img {
+	width: 200px;
+	height: 200px;
+}
+
+.hl88-lr-ga-txt {
+	position: absolute;
+	bottom: 20px;
+	color: #fff;
+	font-size: 24px;
+	font-weight: bold;
+	text-shadow: 0 2px 4px rgba(0,0,0,0.3);
+}
+
+@keyframes hl88-lr-ga-zoomIn {
+	0% {
+		opacity: 0;
+		transform: scale(0.1) translateY(0px);
+	}
+	60% {
+		opacity: 1;
+		transform: scale(1.15) translateY(-20px);
+	}
+	75% {
+		opacity: 1;
+		transform: scale(0.98) translateY(-10px);
+	}
+	85% {
+		opacity: 1;
+		transform: scale(1) translateY(-15px);
+	}
+	100% {
+		opacity: 1;
+		transform: scale(1) translateY(-30px);
+	}
+}
+
+@keyframes hl88-lr-ga-float {
+	0%, 100% {
+		transform: translateY(0) scale(1) rotate(0deg);
+	}
+	25% {
+		transform: translateY(-20px) scale(1.05) rotate(2deg);
+	}
+	50% {
+		transform: translateY(-30px) scale(1.1) rotate(0deg);
+	}
+	75% {
+		transform: translateY(-20px) scale(1.05) rotate(-2deg);
+	}
+}
+
+@keyframes hl88-lr-ga-fadeOut {
+	0% {
+		opacity: 1;
+		transform: translateY(-30px) scale(1.1) rotate(0deg);
+	}
+	100% {
+		opacity: 0;
+		transform: translateY(-80px) scale(0.7) rotate(-5deg);
+	}
+}
+/* #endif */
+
+/* 小程序端动画 */
+/* #ifdef MP */
+.hl88-lr-ga-mp {
+	position: relative;
+	width: 300rpx;
+	height: 300rpx;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	opacity: 0;
+	transform: scale(0.3);
+}
+
+.hl88-lr-ga-mp.hl88-lr-ga-play {
+	animation: hl88-lr-ga-zoomInMp 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards,
+	           hl88-lr-ga-floatMp 1.6s ease-in-out 0.8s infinite,
+	           hl88-lr-ga-fadeOutMp 0.6s ease-in 2.4s forwards;
+}
+
+.hl88-lr-ga-img {
+	width: 200rpx;
+	height: 200rpx;
+}
+
+.hl88-lr-ga-txt {
+	position: absolute;
+	bottom: 20rpx;
+	color: #fff;
+	font-size: 24rpx;
+	font-weight: bold;
+	text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.3);
+}
+
+@keyframes hl88-lr-ga-zoomInMp {
+	0% {
+		opacity: 0;
+		transform: scale(0.1) translateY(0rpx);
+	}
+	60% {
+		opacity: 1;
+		transform: scale(1.15) translateY(-20rpx);
+	}
+	75% {
+		opacity: 1;
+		transform: scale(0.98) translateY(-10rpx);
+	}
+	85% {
+		opacity: 1;
+		transform: scale(1) translateY(-15rpx);
+	}
+	100% {
+		opacity: 1;
+		transform: scale(1) translateY(-30rpx);
+	}
+}
+
+@keyframes hl88-lr-ga-floatMp {
+	0%, 100% {
+		transform: translateY(0) scale(1) rotate(0deg);
+	}
+	25% {
+		transform: translateY(-20rpx) scale(1.05) rotate(2deg);
+	}
+	50% {
+		transform: translateY(-30rpx) scale(1.1) rotate(0deg);
+	}
+	75% {
+		transform: translateY(-20rpx) scale(1.05) rotate(-2deg);
+	}
+}
+
+@keyframes hl88-lr-ga-fadeOutMp {
+	0% {
+		opacity: 1;
+		transform: translateY(-30rpx) scale(1.1) rotate(0deg);
+	}
+	100% {
+		opacity: 0;
+		transform: translateY(-80rpx) scale(0.7) rotate(-5deg);
+	}
+}
+/* #endif */
+</style>
+

+ 805 - 0
components/live-room/gift-panel.vue

@@ -0,0 +1,805 @@
+<!-- 
+	打赏面板组件
+-->
+<template>
+	<view>
+		<uni-popup ref="giftPanel" type="bottom" @change="onChange">
+			<view class="hl88-lr-gp">
+				<view class="hl88-lr-gp-header">
+					<text class="hl88-lr-gp-title">{{ langTexts.selectGift }}</text>
+					<text class="hl88-lr-gp-close" @tap="close">✕</text>
+				</view>
+				
+				<scroll-view scroll-x="true" class="hl88-lr-gp-list">
+					<!-- 加载状态 -->
+					<view v-if="giftLoading && giftList.length === 0" class="hl88-lr-gp-loading">
+						<text class="hl88-lr-gp-loadTx">{{ langTexts.loading }}</text>
+					</view>
+					<!-- 礼物列表 -->
+					<view 
+						v-else-if="giftList.length > 0"
+						class="hl88-lr-gp-item" 
+						v-for="(item, index) in giftList" 
+						:key="item.id || index"
+						:class="{ 'hl88-lr-gp-itemSel': selectedIndex === index }"
+						@tap="selectGift(index)"
+					>
+					<image :src="getGiftImage(item.image)" class="hl88-lr-gp-itemImg" mode="aspectFit"></image>
+					<text class="hl88-lr-gp-itemNm">{{ item.name }}</text>
+					<view class="hl88-lr-gp-itemPr">
+						<image src="/static/imgs/icon/ic_diamond.png" class="hl88-lr-gp-itemPrIc" mode="aspectFit"></image>
+						<text class="hl88-lr-gp-itemPrTx">{{ item.price }}</text>
+					</view>
+					</view>
+					<!-- 空状态 -->
+					<view v-else class="hl88-lr-gp-empty">
+						<text class="hl88-lr-gp-emptyTx">{{ langTexts.noGifts }}</text>
+					</view>
+				</scroll-view>
+				
+				<view class="hl88-lr-gp-footer">
+					<view class="hl88-lr-gp-balance">
+						<text class="hl88-lr-gp-balLb">{{ langTexts.balance }}</text>
+						<text class="hl88-lr-gp-balVal">{{ balance }}</text>
+					</view>
+					<view class="hl88-lr-gp-actions">
+						<view class="hl88-lr-gp-cntSel">
+							<text class="hl88-lr-gp-cntBtn" @tap="decreaseCount">-</text>
+							<text class="hl88-lr-gp-cntVal">{{ count }}</text>
+							<text class="hl88-lr-gp-cntBtn" @tap="increaseCount">+</text>
+						</view>
+						<view class="hl88-lr-gp-sendBtn" @tap="sendGift">
+							<text class="hl88-lr-gp-sendTx">{{ langTexts.send }} ({{ totalPrice }})</text>
+						</view>
+					</view>
+				</view>
+			</view>
+		</uni-popup>
+		
+		<!-- 钻石余额不足弹框 -->
+		<live-room-insufficient-balance-modal
+			:show="showInsufficientBalanceModal"
+			:title="langTexts.insufficientBalance"
+			:subtitle="langTexts.rechargeToContinue"
+			:buttonText="langTexts.goToRecharge"
+			@close="closeInsufficientBalanceModal"
+			@confirm="goToRecharge"
+		/>
+	</view>
+</template>
+
+<script>
+import liveRoomInsufficientBalanceModal from '@/components/live-room/insufficient-balance-modal.vue';
+
+export default {
+	name: 'LiveRoomGiftPanel',
+	components: {
+		liveRoomInsufficientBalanceModal
+	},
+	data() {
+		return {
+			selectedIndex: 0,
+			count: 1,
+			balance: 0,
+			giftList: [],
+			giftLoading: false,
+			current_page: 1,
+			last_page: 1,
+			cdnUrl: '',
+			// 是否显示余额不足弹框
+			showInsufficientBalanceModal: false,
+			// 待发送的礼物信息(余额不足时保存)
+			pendingGiftInfo: null,
+			// 语言文本变量
+			langTexts: {
+				selectGift: '',
+				loading: '',
+				noGifts: '',
+				balance: '',
+				send: '',
+				pleaseSelectGift: '',
+				missingLiveId: '',
+				insufficientBalance: '',
+				rechargeToContinue: '',
+				goToRecharge: '',
+				sendFailed: '',
+				success: '',
+				defaultGiftName: ''
+				}
+		}
+	},
+	computed: {
+		totalPrice() {
+			const selectedGift = this.giftList[this.selectedIndex]
+			return selectedGift ? selectedGift.price * this.count : 0
+		}
+	},
+	mounted() {
+		this.cdnUrl = getApp().globalData.config.cdnurl;
+		// 初始化语言文本
+		this.initLanguageStrings();
+		this.loadBalance()
+		this.loadGiftList()
+	},
+	methods: {
+		// 初始化语言文本
+		initLanguageStrings() {
+			const locale = uni.getLocale() || 'zh-Hans';
+			const savedLanguage = uni.getStorageSync('language');
+			const currentLang = savedLanguage || locale;
+			
+			// 语言文本映射
+			const languageMap = {
+				'zh-Hans': {
+					selectGift: '选择礼物',
+					loading: '加载中...',
+					noGifts: '暂无礼物',
+					balance: '钻石:',
+					send: '送出',
+					pleaseSelectGift: '请选择礼物',
+					missingLiveId: '缺少直播间ID',
+					insufficientBalance: '余额不足',
+					sendFailed: '打赏失败',
+					success: '成功',
+					defaultGiftName: '礼物'
+				},
+				'zh-Hant': {
+					selectGift: '選擇禮物',
+					loading: '加載中...',
+					noGifts: '暫無禮物',
+					balance: '鑽石:',
+					send: '送出',
+					pleaseSelectGift: '請選擇禮物',
+					missingLiveId: '缺少直播間ID',
+					insufficientBalance: '餘額不足',
+					rechargeToContinue: '儲值後才可繼續購買',
+					goToRecharge: '去儲值',
+					sendFailed: '打賞失敗',
+					success: '成功',
+					defaultGiftName: '禮物'
+				},
+				'en': {
+					selectGift: 'Select Gift',
+					loading: 'Loading...',
+					noGifts: 'No gifts available',
+					balance: 'Diamond: ',
+					send: 'Send',
+					pleaseSelectGift: 'Please select a gift',
+					missingLiveId: 'Missing live room ID',
+					insufficientBalance: 'Insufficient balance',
+					rechargeToContinue: 'Recharge to continue purchasing',
+					goToRecharge: 'Recharge',
+					sendFailed: 'Send failed',
+					success: 'Success',
+					defaultGiftName: 'Gift'
+				},
+				'en-SG': {
+					selectGift: 'Select Gift',
+					loading: 'Loading...',
+					noGifts: 'No gifts available',
+					balance: 'Diamond: ',
+					send: 'Send',
+					pleaseSelectGift: 'Please select a gift',
+					missingLiveId: 'Missing live room ID',
+					insufficientBalance: 'Insufficient balance',
+					rechargeToContinue: 'Recharge to continue purchasing',
+					goToRecharge: 'Recharge',
+					sendFailed: 'Send failed',
+					success: 'Success',
+					defaultGiftName: 'Gift'
+				},
+				'en-MY': {
+					selectGift: 'Select Gift',
+					loading: 'Loading...',
+					noGifts: 'No gifts available',
+					balance: 'Diamond: ',
+					send: 'Send',
+					pleaseSelectGift: 'Please select a gift',
+					missingLiveId: 'Missing live room ID',
+					insufficientBalance: 'Insufficient balance',
+					rechargeToContinue: 'Recharge to continue purchasing',
+					goToRecharge: 'Recharge',
+					sendFailed: 'Send failed',
+					success: 'Success',
+					defaultGiftName: 'Gift'
+				},
+				'ja': {
+					selectGift: 'ギフトを選択',
+					loading: '読み込み中...',
+					noGifts: 'ギフトがありません',
+					balance: '残高:',
+					send: '送る',
+					pleaseSelectGift: 'ギフトを選択してください',
+					missingLiveId: 'ライブルームIDが不足しています',
+					insufficientBalance: '残高不足',
+					rechargeToContinue: 'チャージ後に購入を続けることができます',
+					goToRecharge: 'チャージへ',
+					sendFailed: '送信失敗',
+					success: '成功',
+					defaultGiftName: 'ギフト'
+				}
+			};
+			
+			// 根据当前语言设置文本,如果不存在则使用简体中文
+			const texts = languageMap[currentLang] || languageMap['zh-Hans'];
+			this.langTexts = {
+				selectGift: texts.selectGift,
+				loading: texts.loading,
+				noGifts: texts.noGifts,
+				balance: texts.balance,
+				send: texts.send,
+				pleaseSelectGift: texts.pleaseSelectGift,
+				missingLiveId: texts.missingLiveId,
+				insufficientBalance: texts.insufficientBalance,
+				rechargeToContinue: texts.rechargeToContinue,
+				goToRecharge: texts.goToRecharge,
+				sendFailed: texts.sendFailed,
+				success: texts.success,
+				defaultGiftName: texts.defaultGiftName
+			};
+		},
+		open() {
+			this.$refs.giftPanel.open()
+		},
+		close() {
+			this.$refs.giftPanel.close()
+		},
+		onChange(e) {
+			this.$emit('change', e)
+		},
+		selectGift(index) {
+			this.selectedIndex = index
+			this.count = 1
+		},
+		increaseCount() {
+			this.count++
+		},
+		decreaseCount() {
+			if (this.count > 1) {
+				this.count--
+			}
+		},
+		sendSimulationGift(){
+			const selectedGift = this.giftList[this.selectedIndex]
+			if (!selectedGift) return
+			
+			// 模拟发送礼物(用于测试)
+			this.$emit('gift-sent', {
+				gift: selectedGift,
+				count: this.count,
+				totalPrice: this.totalPrice
+			})
+		},
+		async sendGift() {
+			const selectedGift = this.giftList[this.selectedIndex]
+			if (!selectedGift) {
+				uni.showToast({
+					title: this.langTexts.pleaseSelectGift,
+					icon: 'none'
+				})
+				return
+			}
+			
+			if (!this.liveId) {
+				uni.showToast({
+					title: this.langTexts.missingLiveId,
+					icon: 'none'
+				})
+				return
+			}
+			
+			// 检查余额
+			if (this.totalPrice > this.balance) {
+				// 余额不足,保存礼物信息,然后显示余额不足弹框
+				this.pendingGiftInfo = {
+					liveId: this.liveId,
+					giftId: selectedGift.id,
+					giftName: selectedGift.name,
+					giftImage: selectedGift.image,
+					count: this.count,
+					totalPrice: this.totalPrice,
+					timestamp: Date.now()
+				};
+				
+				// 显示余额不足弹框
+				this.showInsufficientBalanceModal = true;
+				return;
+			}
+			
+			// 发送打赏请求
+			try {
+				const [err, res] = await uni.request({
+					url: '/wanlshop/pay/sendgift',
+					method: 'POST',
+					data: {
+						id: Number(this.liveId),
+						gift_id: Number(selectedGift.id),
+						number: Number(this.count) || 1
+					}
+				})
+				
+				// 兼容不同的返回格式
+				if (err) {
+					uni.showToast({
+						title: this.langTexts.sendFailed,
+						icon: 'none'
+					})
+					return
+				}
+				
+				// 处理返回数据:可能的结构
+				// 1. res.data.res = {code: 1, msg: "...", data: null}
+				// 2. res.data = {code: 1, msg: "...", data: null}
+				// 3. res = {code: 1, msg: "...", data: null}
+				const body = res && res.data ? res.data : res
+				const response = (body && body.res) ? body.res : body
+				
+				if (response && Number(response.code) === 1) {
+					// 触发动画事件
+					this.$emit('gift-sent', {
+						gift: selectedGift,
+						count: this.count,
+						totalPrice: this.totalPrice
+					})
+					
+					// 更新余额(如果接口返回了余额)
+					if (response.data && typeof response.data === 'string') {
+						// 如果返回的 data 是余额字符串
+						this.balance = Number(response.data) || 0
+					} else if (response.balance !== undefined) {
+						// 如果返回了 balance 字段
+						this.balance = Number(response.balance) || 0
+					} else {
+						// 如果没有返回余额,则扣减本地余额
+						this.balance = Math.max(0, this.balance - this.totalPrice)
+					}
+					
+					// 关闭面板
+					this.close()
+					
+					uni.showToast({
+						title: this.langTexts.success,
+						icon: 'success'
+					})
+				} else {
+					uni.showToast({
+						title: (response && response.msg) ? response.msg : this.langTexts.sendFailed,
+						icon: 'none'
+					})
+				}
+			} catch (error) {
+				console.error('Send gift error:', error)
+				uni.showToast({
+					title: this.langTexts.sendFailed,
+					icon: 'none'
+				})
+			}
+		},
+		async loadBalance() {
+			try {
+				const [err, res] = await uni.request({
+					url: '/user/getdiamondnum'
+				})
+				
+				if (err) {
+					console.error('Load balance error:', err)
+					return
+				}
+				
+				// 兼容不同的返回格式
+				// 情况1: res.data 直接是字符串 "1111111.00"
+				if (typeof res.data === 'string') {
+					this.balance = Number(res.data) || 0
+					return
+				}
+				
+				// 情况2: res.data 是对象 {diamond: 558} 或 {data: {diamond: 558}}
+				if (res.data != null && typeof res.data === 'object') {
+					// 直接包含 diamond 字段
+					if (res.data.diamond != null) {
+						this.balance = Number(res.data.diamond) || 0
+						return
+					}
+					// 嵌套在 data 字段中: {data: {diamond: 558}}
+					if (res.data.data != null && typeof res.data.data === 'object' && res.data.data.diamond != null) {
+						this.balance = Number(res.data.data.diamond) || 0
+						return
+					}
+				}
+				
+				// 情况3: res.data 是对象 {code: 1, data: "1111111.00"} 或 {code: 1, data: {diamond: 558}}
+				const body = res && res.data ? res.data : res
+				
+				if (body && typeof body === 'object') {
+					// 如果 body 有 code 字段,说明是标准响应格式
+					if (body.code !== undefined) {
+						if (Number(body.code) === 1) {
+							// data 可能是字符串或对象
+							if (typeof body.data === 'string') {
+								this.balance = Number(body.data) || 0
+							} else if (body.data && typeof body.data === 'object' && body.data.diamond != null) {
+								this.balance = Number(body.data.diamond) || 0
+							} else {
+								this.balance = 0
+							}
+						} else {
+							this.balance = 0
+						}
+					} else if (body.diamond != null) {
+						// 直接包含 diamond 字段
+						this.balance = Number(body.diamond) || 0
+					} else {
+						this.balance = 0
+					}
+				} else {
+					// 其他情况,尝试直接转换
+					this.balance = Number(body) || 0
+				}
+			} catch (error) {
+				console.error('Load balance error:', error)
+				this.balance = 0
+			}
+		},
+		// 加载礼物列表
+		async loadGiftList() {
+			if (this.giftLoading) return
+			this.giftLoading = true
+			try {
+				const [err, res] = await uni.request({
+					url: '/wanlshop/livegift/list',
+					method: 'GET',
+					data: {
+						page: this.current_page
+					}
+				})
+				
+				if (err) {
+					console.error('Load gift list error:', err)
+					this.giftLoading = false
+					return
+				}
+				
+				// 兼容不同的返回格式
+				const body = res && res.data ? res.data : res
+				const payload = (body && body.code !== undefined) ? body.data : body
+				
+				// 提取列表
+				const list = Array.isArray(payload)
+					? payload
+					: (payload && payload.data ? payload.data : [])
+				
+				// 提取分页信息
+				const currentPage = 
+					(payload && payload.current_page) ? payload.current_page :
+					(body && body.current_page) ? body.current_page :
+					this.current_page
+				const lastPage = 
+					(payload && payload.last_page) ? payload.last_page :
+					(body && body.last_page) ? body.last_page :
+					currentPage
+				
+				// 更新分页信息
+				this.current_page = Number(currentPage)
+				this.last_page = Number(lastPage)
+				
+				// 更新礼物列表
+				if (list && list.length > 0) {
+					// 格式化礼物数据,根据接口返回的数据结构
+					this.giftList = list.map(item => ({
+						id: item.id,
+						name: item.title || item.name || item.gift_name || this.langTexts.defaultGiftName,
+						image: item.image || item.gift_image || item.icon || '',
+						price: Number(item.price || item.gift_price || 0),
+						type: item.type || item.gift_type || 'default',
+						svga: item.show_image || item.svga || item.animation || ''
+					}))
+					
+					// 如果有选中的礼物,确保索引有效
+					if (this.selectedIndex >= this.giftList.length) {
+						this.selectedIndex = 0
+					}
+				} else {
+					// 如果没有数据,保持空列表
+					this.giftList = []
+				}
+			} catch (error) {
+				console.error('Load gift list error:', error)
+			} finally {
+				this.giftLoading = false
+			}
+		},
+		// 获取礼物图片完整URL
+		getGiftImage(imageUrl) {
+			if (!imageUrl) return ''
+			// 如果已经是完整URL,直接返回
+			if (/^(http|https):\/\/.+/.test(imageUrl)) {
+				return imageUrl
+			}
+			// 否则拼接 cdnUrl
+			return this.cdnUrl ? (this.cdnUrl + imageUrl) : imageUrl
+		},
+		// 关闭余额不足弹框
+		closeInsufficientBalanceModal() {
+			this.showInsufficientBalanceModal = false;
+			this.pendingGiftInfo = null;
+		},
+		// 跳转到充值页面
+		goToRecharge() {
+			// 关闭余额不足弹框
+			this.closeInsufficientBalanceModal();
+			
+			// 关闭礼物面板
+			this.close();
+			
+			// 保存礼物信息到本地存储
+			if (this.pendingGiftInfo) {
+				uni.setStorageSync('pending_gift', this.pendingGiftInfo);
+			}
+			
+			// 跳转到充值页面,传递直播间ID
+			uni.navigateTo({
+				url: '/pages/user/wallet/diamond-top-up?from=gift&liveId=' + this.liveId
+			});
+		}
+	},
+	props: {
+		liveId: {
+			type: Number,
+			default: 0
+		}
+	}
+}
+</script>
+
+<style scoped>
+.hl88-lr-gp {
+	background-color: rgba(255, 255, 255, 0.95);
+	border-top-left-radius: 20rpx;
+	border-top-right-radius: 20rpx;
+	/* #ifndef APP-NVUE */
+	padding-bottom: env(safe-area-inset-bottom);
+	/* #endif */
+	/* #ifdef APP-NVUE */
+	padding-bottom: 20rpx;
+	/* #endif */
+}
+
+.hl88-lr-gp-header {
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	padding: 30rpx;
+	border-bottom-width: 1rpx;
+	border-bottom-style: solid;
+	border-bottom-color: #eeeeee;
+}
+
+.hl88-lr-gp-title {
+	font-size: 32rpx;
+	font-weight: bold;
+	color: #333;
+}
+
+.hl88-lr-gp-close {
+	font-size: 40rpx;
+	color: #999999;
+	width: 60rpx;
+	height: 60rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+}
+
+.hl88-lr-gp-list {
+	/* #ifndef APP-NVUE */
+	white-space: nowrap;
+	/* #endif */
+	padding-top: 20rpx;
+	padding-bottom: 20rpx;
+	/* #ifdef APP-NVUE */
+	flex-direction: row;
+	/* #endif */
+}
+
+.hl88-lr-gp-item {
+	/* #ifndef APP-NVUE */
+	display: inline-block;
+	/* #endif */
+	width: 160rpx;
+	margin-left: 15rpx;
+	margin-right: 15rpx;
+	/* #ifndef APP-NVUE */
+	text-align: center;
+	/* #endif */
+	/* #ifdef APP-NVUE */
+	align-items: center;
+	justify-content: center;
+	flex-direction: column;
+	/* #endif */
+	padding: 20rpx;
+	border-radius: 15rpx;
+	border-width: 2rpx;
+	border-style: solid;
+	border-color: transparent;
+	/* #ifndef APP-NVUE */
+	transition: all 0.3s;
+	/* #endif */
+}
+
+.hl88-lr-gp-itemSel {
+	border-color: #f72b36;
+	background: rgba(247, 43, 54, 0.1);
+}
+
+.hl88-lr-gp-itemImg {
+	width: 100rpx;
+	height: 100rpx;
+	margin-bottom: 10rpx;
+	/* #ifdef APP-NVUE */
+	margin-top: 0;
+	margin-left: 0;
+	margin-right: 0;
+	/* #endif */
+}
+
+.hl88-lr-gp-itemNm {
+	/* #ifndef APP-NVUE */
+	display: block;
+	/* #endif */
+	font-size: 24rpx;
+	color: #333333;
+	margin-bottom: 5rpx;
+}
+
+.hl88-lr-gp-itemPr {
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	flex-direction: row;
+	align-items: center;
+	justify-content: center;
+	font-size: 22rpx;
+	color: #f72b36;
+	font-weight: bold;
+}
+
+.hl88-lr-gp-itemPrIc {
+	width: 24rpx;
+	height: 24rpx;
+	margin-right: 4rpx;
+}
+
+.hl88-lr-gp-itemPrTx {
+	font-size: 22rpx;
+	color: #f72b36;
+	font-weight: bold;
+}
+
+.hl88-lr-gp-footer {
+	padding: 20rpx 30rpx;
+	border-top-width: 1rpx;
+	border-top-style: solid;
+	border-top-color: #eeeeee;
+}
+
+.hl88-lr-gp-balance {
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 20rpx;
+}
+
+.hl88-lr-gp-balLb {
+	font-size: 28rpx;
+	color: #666;
+}
+
+.hl88-lr-gp-balVal {
+	font-size: 32rpx;
+	color: #f72b36;
+	font-weight: bold;
+}
+
+.hl88-lr-gp-actions {
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	flex-direction: row;
+	justify-content: space-between;
+	align-items: center;
+}
+
+.hl88-lr-gp-cntSel {
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	flex-direction: row;
+	align-items: center;
+	border-width: 1rpx;
+	border-style: solid;
+	border-color: #dddddd;
+	border-radius: 10rpx;
+	/* #ifndef APP-NVUE */
+	overflow: hidden;
+	/* #endif */
+}
+
+.hl88-lr-gp-cntBtn {
+	width: 60rpx;
+	height: 60rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+	font-size: 32rpx;
+	color: #333333;
+	background-color: #f5f5f5;
+}
+
+.hl88-lr-gp-cntVal {
+	min-width: 80rpx;
+	/* #ifndef APP-NVUE */
+	text-align: center;
+	/* #endif */
+	font-size: 28rpx;
+	color: #333333;
+	padding-left: 20rpx;
+	padding-right: 20rpx;
+}
+
+.hl88-lr-gp-sendBtn {
+	/* #ifndef APP-NVUE */
+	background: linear-gradient(135deg, #f72b36 0%, #ff6b6b 100%);
+	/* #endif */
+	/* #ifdef APP-NVUE */
+	background-color: #f72b36;
+	/* #endif */
+	border-radius: 50rpx;
+	padding: 20rpx 40rpx;
+	margin-left: 20rpx;
+}
+
+.hl88-lr-gp-sendTx {
+	color: #fff;
+	font-size: 28rpx;
+	font-weight: bold;
+}
+
+.hl88-lr-gp-loading {
+	padding: 40rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+}
+
+.hl88-lr-gp-loadTx {
+	font-size: 28rpx;
+	color: #999999;
+}
+
+.hl88-lr-gp-empty {
+	padding: 40rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+}
+
+.hl88-lr-gp-emptyTx {
+	font-size: 28rpx;
+	color: #999999;
+}
+</style>
+

+ 264 - 0
components/live-room/header.vue

@@ -0,0 +1,264 @@
+
+<template>
+	<view class="hl88-lr-header">
+		<view class="hl88-lr-hdRight round">
+			<view class="hl88-lr-hdAvWrap justify-center bg-white round align-center">
+				<image class="hl88-lr-hdAvImg round" :src="displayAvatar"/>
+			</view>
+			<view class="hl88-lr-hdShop">
+				<text class="hl88-lr-hdShopNm"> {{ formatEllipsis(displayName, 16) }} </text>
+				<view class="hl88-lr-hdShopMeta">
+					<text class="hl88-lr-hdShopMetaTxt">{{ numFormat(online) }} {{ headerLangTexts.watching }}</text>
+				</view>
+			</view>
+			<view
+				v-if="showFollowButton"
+				class="hl88-lr-hdFollowBtn"
+				:class="{ 'hl88-lr-hdFollowOn': isFollowing }"
+				@tap.stop="like"
+			>
+				<text class="hl88-lr-hdFollowTxt" :class="{ 'hl88-lr-hdFollowTxtOn': isFollowing }">{{ isFollowing ? headerLangTexts.followed : headerLangTexts.follow }}</text>
+			</view>
+		</view>
+		<view class="hl88-lr-hdLeft">
+			<view class="hl88-lr-btn-icon" @tap="back()">
+				<image class="hl88-lr-btn-icon-img" src="/static/imgs/close.png" mode="aspectFit" />
+			</view>
+		</view>
+	</view>
+</template>
+<script>
+	export default {
+		name: 'LiveRoomHeader',
+		props: {
+			online: {
+				type: Number,
+				default: 0
+			},
+			state: {
+				default: 0
+			},
+			// 与 user 页一致:0 未关注,1 已关注(接口也可能返回布尔)
+			isFollow: {
+				type: [Boolean, Number],
+				default: 0
+			},
+			shopdata: {
+				type: Object,
+				default: function() {
+					return {};
+				}
+			},
+			// 为 true 时点击返回不直接后退,由父组件监听 @back 先弹框确认后再退出
+			confirmBeforeBack: {
+				type: Boolean,
+				default: false
+			},
+			// 主播端等场景可不展示关注按钮(默认展示,与观众端直播间一致)
+			showFollowButton: {
+				type: Boolean,
+				default: true
+			},
+			/** 当前直播间 id,与 getLiveStatus /wanlshop/live/getLiveStatus 的 data.id 一致;关注时回传给父页 */
+			liveId: {
+				type: [String, Number],
+				default: ''
+			}
+		},
+		computed: {
+			hasShopAvatar() {
+				return !!(this.shopdata && this.shopdata.avatar);
+			},
+			displayAvatar() {
+				const avatar = this.hasShopAvatar
+					? this.shopdata.avatar
+					: (this.$store.state.user && this.$store.state.user.avatar);
+				return this.stcOss(avatar);
+			},
+			displayName() {
+				if (this.hasShopAvatar) {
+					return (this.shopdata && this.shopdata.shopname) || '';
+				}
+				const user = this.$store.state.user || {};
+				return user.nickname || user.username || '';
+			},
+			/**
+			 * nvue 直播间内 $t 可能不可用;与 pages/apps/find/details/live-room.nvue 中 initLanguageStrings 一致:
+			 * uni.getStorageSync('language') || uni.getLocale(),再按 languageMap 取值。
+			 */
+			headerLangTexts() {
+				const locale = uni.getLocale() || 'zh-Hans';
+				const savedLanguage = uni.getStorageSync('language');
+				const currentLang = savedLanguage || locale;
+				const languageMap = {
+					'zh-Hans': {
+						follow: '关注',
+						followed: '已关注',
+						watching: '在看'
+					},
+					'zh-Hant': {
+						follow: '關注',
+						followed: '已關注',
+						watching: '在看'
+					},
+					'en': {
+						follow: 'Follow',
+						followed: 'Followed',
+						watching: 'watching'
+					},
+					'en-SG': {
+						follow: 'Follow',
+						followed: 'Followed',
+						watching: 'watching'
+					},
+					'en-MY': {
+						follow: 'Follow',
+						followed: 'Followed',
+						watching: 'watching'
+					},
+					'ja': {
+						follow: 'フォロー',
+						followed: 'フォロー済み',
+						watching: '視聴中'
+					}
+				};
+				return languageMap[currentLang] || languageMap['zh-Hans'];
+			},
+			/** 与接口一致:0 未关注,大于 0(含多种枚举值)已关注 */
+			isFollowing() {
+				const f = this.isFollow;
+				if (f === true) return true;
+				const n = Number(f);
+				return !Number.isNaN(n) && n > 0;
+			}
+		},
+		methods: {
+			like() {
+				// 避免与原生 change 冲突,父页使用 @followTap;携带当前直播间 id(同 getLiveStatus 的 id)
+				this.$emit('followTap', this.liveId);
+			},
+			back() {
+				if (this.confirmBeforeBack) {
+					this.$emit('back');
+				} else {
+					uni.navigateBack({
+						delta: 1
+					});
+				}
+			},
+			stcOss(url) {
+				let oss = (this.$store.state.shell && this.$store.state.shell.appUrl && this.$store.state.shell.appUrl.oss) || '';
+				let image = '';
+				if (url) {
+					if ((/^(http|https):\/\/.+/.test(url))) {
+						image = url;
+					} else {
+						image = oss + url;
+					}
+				} else {
+					image = oss + '/assets/addons/wanlshop/img/common/img_default3x.png';
+				}
+				return image;
+			},
+			numFormat(num) {
+				return num > 9999 ? ((num - num % 1000) / 10000 + 'W') : num
+			},
+			formatEllipsis(str = '', limitLen = 24) {
+				let len = 0,
+				reg = /[\x00-\xff]/, //半角字符的正则匹配
+				strs = str.split(''),
+				inx = strs.findIndex(s => {
+					len += reg.test(s) ? 1 : 2
+					if (len > limitLen) return true
+				})
+			    return inx === -1 ? str : str.substr(0, inx) + '...'
+			}
+		}
+	}
+</script>
+
+<style>
+	@import '@/static/style/wanlnvue.css';
+	.hl88-lr-header {
+		background-color: transparent;
+		justify-content: space-between;
+		align-items: center;
+		flex-direction: row;
+		padding-top: 16rpx;
+		padding-left: 24rpx;
+		padding-right: 24rpx;
+		/* #ifdef MP */
+		padding-right: 200rpx;
+		/* #endif */
+	}
+		.hl88-lr-hdRight{
+			align-items: center;
+			flex-direction: row;
+			background-color: rgba(0,0,0,.2);
+			padding-top: 8rpx;
+			padding-right: 16rpx;
+			padding-bottom: 8rpx;
+			padding-left: 8rpx;
+		}
+			.hl88-lr-hdAvWrap{
+				width: 68rpx;
+				height: 68rpx;
+			}
+			.hl88-lr-hdAvImg{
+				width: 68rpx;
+				height: 68rpx;
+			}
+			.hl88-lr-hdShop{
+				padding-left: 15rpx;
+			}
+				.hl88-lr-hdShopNm{
+					color: rgba(255, 255, 255, .8);
+					font-size: 28rpx;
+					line-height: 36rpx;
+				}
+				.hl88-lr-hdShopMetaTxt{
+					color: rgba(255, 255, 255, .7);
+					font-size: 20rpx;
+				}
+			.hl88-lr-hdFollowBtn{
+				background-color: #f40;
+				min-width: 86rpx;
+				padding-left: 12rpx;
+				padding-right: 12rpx;
+				height: 50rpx;
+				justify-content: center;
+				align-items: center;
+				border-radius: 18rpx;
+				margin-left: 30rpx;
+			}
+			.hl88-lr-hdFollowOn {
+				background-color: rgba(0, 0, 0, 0);
+				border-width: 2rpx;
+				border-style: solid;
+				border-color: rgba(255, 255, 255, 0.85);
+			}
+				.hl88-lr-hdFollowTxt{
+					color: #fff;
+					font-size: 24rpx;
+				}
+				.hl88-lr-hdFollowTxtOn {
+					color: rgba(255, 255, 255, 0.95);
+				}
+		.hl88-lr-hdLeft{
+			align-items: center;
+			justify-content: center;
+		}
+		.hl88-lr-btn-icon{
+			width: 76rpx;
+			height: 76rpx;
+			border-radius: 38rpx;
+			background-color: rgba(0, 0, 0, 0.2);
+			align-items: center;
+			justify-content: center;
+		}
+		.hl88-lr-btn-icon-img{
+			width: 40rpx;
+			height: 40rpx;
+		}
+</style>
+

+ 64 - 0
components/live-room/image.vue

@@ -0,0 +1,64 @@
+<template>
+	<view class="hl88-lr-cover">
+		<image 
+			class="hl88-lr-cover-img"
+			:lazy-load="true" 
+			:fade-show="false" 
+			:src="stcOss(image)" 
+			mode="aspectFill" 
+			:style="{ height: screenHeight + 'px', width: screenWidth + 'px'}"
+		></image>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'LiveRoomCoverImage',
+	props: {
+		screenHeight: {
+			default: 0
+		},
+		screenWidth: {
+			default: 0
+		},
+		image: {
+			type: String,
+			default: ''
+		}
+	},
+	methods: {
+		stcOss(url) {
+				let oss = (this.$store.state.shell && this.$store.state.shell.appUrl && this.$store.state.shell.appUrl.oss) || '';
+			let image = '';
+			if (url) {
+				if (/^(http|https):\/\/.+/.test(url)) {
+					image = url;
+				} else {
+					image = oss + url;
+				}
+			} else {
+				image = '';
+			}
+			return image;
+		}
+	}
+};
+</script>
+
+<style>
+.hl88-lr-cover {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	align-items: center;
+	justify-content: center;
+}
+.hl88-lr-cover-img{
+	/* #ifndef APP-PLUS */
+	filter: blur(10px);
+	/* #endif */
+	background-color: #000000;
+}
+</style>

+ 167 - 0
components/live-room/insufficient-balance-modal.vue

@@ -0,0 +1,167 @@
+<template>
+	<view v-if="show" class="hl88-lr-insufficient-balance-overlay" @tap="handleMaskClick">
+		<view class="hl88-lr-insufficient-balance-modal" @tap.stop="">
+			<!-- 图标 -->
+			<view class="hl88-lr-insufficient-balance-icon-wrap">
+				<image class="hl88-lr-insufficient-balance-icon" src="/static/imgs/icon/ic_buy_diamond.png" mode="aspectFit"></image>
+			</view>
+			<!-- 标题 -->
+			<text class="hl88-lr-insufficient-balance-title">{{ title }}</text>
+			<!-- 副标题 -->
+			<text class="hl88-lr-insufficient-balance-subtitle">{{ subtitle }}</text>
+			<!-- 按钮 -->
+			<view class="hl88-lr-insufficient-balance-button" :style="buttonStyle" @tap="handleButtonClick">
+				<text class="hl88-lr-insufficient-balance-button-text">{{ buttonText }}</text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'LiveRoomInsufficientBalanceModal',
+	props: {
+		// 是否显示
+		show: {
+			type: Boolean,
+			default: false
+		},
+		// 标题
+		title: {
+			type: String,
+			default: '钻石余额不足'
+		},
+		// 副标题
+		subtitle: {
+			type: String,
+			default: '储值后才可继续购买'
+		},
+		// 按钮文字
+		buttonText: {
+			type: String,
+			default: '去储值'
+		},
+		// 点击遮罩是否关闭
+		maskClosable: {
+			type: Boolean,
+			default: true
+		}
+	},
+	computed: {
+		// nvue 中需要使用 backgroundColor 而不是 linear-gradient
+		buttonStyle() {
+			// #ifdef APP-NVUE
+			return {
+				backgroundColor: '#ff6b6b' // 使用渐变色的主色
+			};
+			// #endif
+			// #ifndef APP-NVUE
+			return {};
+			// #endif
+		}
+	},
+	methods: {
+		handleMaskClick() {
+			if (this.maskClosable) {
+				this.$emit('close');
+			}
+		},
+		handleButtonClick() {
+			this.$emit('confirm');
+		}
+	}
+};
+</script>
+
+<style scoped>
+.hl88-lr-insufficient-balance-overlay {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.5);
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+	z-index: 1000;
+}
+
+.hl88-lr-insufficient-balance-modal {
+	background-color: rgba(255, 255, 255, 0.95);
+	border-radius: 32rpx;
+	width: 600rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	flex-direction: column;
+	align-items: center;
+	padding: 60rpx 40rpx 40rpx;
+	/* #ifndef APP-NVUE */
+	box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.2);
+	/* #endif */
+}
+
+.hl88-lr-insufficient-balance-icon-wrap {
+	margin-bottom: 40rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+}
+
+.hl88-lr-insufficient-balance-icon {
+	width: 200rpx;
+	height: 200rpx;
+}
+
+.hl88-lr-insufficient-balance-title {
+	color: #333333;
+	font-size: 36rpx;
+	font-weight: bold;
+	margin-bottom: 20rpx;
+	text-align: center;
+	/* #ifndef APP-NVUE */
+	display: block;
+	/* #endif */
+}
+
+.hl88-lr-insufficient-balance-subtitle {
+	color: #666666;
+	font-size: 28rpx;
+	margin-bottom: 50rpx;
+	text-align: center;
+	/* #ifndef APP-NVUE */
+	display: block;
+	/* #endif */
+}
+
+.hl88-lr-insufficient-balance-button {
+	/* #ifdef APP-NVUE */
+	background-color: #ff6b6b;
+	/* #endif */
+	/* #ifndef APP-NVUE */
+	background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
+	/* #endif */
+	border-radius: 50rpx;
+	width: 100%;
+	height: 88rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+	/* #ifndef APP-NVUE */
+	box-shadow: 0 8rpx 24rpx rgba(238, 90, 111, 0.4);
+	/* #endif */
+}
+
+.hl88-lr-insufficient-balance-button-text {
+	color: #ffffff;
+	font-size: 32rpx;
+	font-weight: 500;
+}
+</style>

+ 278 - 0
components/live-room/message.vue

@@ -0,0 +1,278 @@
+<template>
+	<view class="hl88-lr-message">
+		<view class="hl88-lr-comment">
+			<view class="hl88-lr-comment-item">
+				 <scroll-view scroll-y="true" :show-scrollbar="false" :scroll-into-view="anchorScrollIntoId" :scroll-with-animation="true" class="hl88-lr-comment-item-scroll">
+					<!-- #ifdef APP -->
+					<view class="hl88-lr-comment-item-scroll-app" v-for="(item, index) in msgList" :key="index" :id="'msg' + index">
+						<view class="hl88-lr-comment-item-scroll-app-item">
+							<text :class="[iscolor(item.form.id)]" class="hl88-lr-msgAppNm">{{item.form.nickname}}:</text>
+							<text class="hl88-lr-msgAppBody">{{item.text}}</text>
+						</view>
+					</view>
+					<!-- #endif -->
+					<!-- #ifdef H5 -->
+					<div class="hl88-lr-comment-item-scroll-h5" v-for="(item, index) in msgList" :key="index" :id="'msg' + index">
+					 	<div class="hl88-lr-comment-item-tag">
+					 		<text :class="[iscolor(item.form.id)]" class="hl88-lr-msgWebNm">{{item.form.nickname}}:</text>
+							<text class="hl88-lr-msgWebBody">{{item.text}}</text>
+					 	</div>
+					</div>
+					<!-- #endif -->
+		            <!-- #ifdef MP -->
+					<div class="hl88-lr-comment-item-scroll-mp" v-for="(item, index) in msgList" :key="index" :id="'msg' + index">
+					 	<div class="hl88-lr-comment-item-tag">
+					 		<text :class="[iscolor(item.form.id)]" class="hl88-lr-msgWebNm">{{item.form.nickname}}:</text>
+							<text class="hl88-lr-msgWebBody">{{item.text}}</text>
+					 	</div>
+					</div>
+		            <!-- #endif -->
+		        </scroll-view>
+			</view>
+			<view class="hl88-lr-like">
+				<!-- #ifndef APP -->
+				<view v-for="(item,index) in likeList" :data-t="index" :key="index" :class="item.class"></view>
+				<!-- #endif -->
+			</view>
+		</view>
+		<uni-transition 
+			:mode-class="['slide-left']" 
+			:styles="{ 'position':'absolute', 'left':'0', 'top':'-60rpx', }"
+			:show="enterNoticeVisible">
+			<view class="hl88-lr-coming">
+				<text class="hl88-lr-coming-text">{{ enterNoticeNickname }} 他来了他来了~</text>
+			</view>
+		</uni-transition>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: 'LiveRoomMessage',
+		data() {
+			return {
+				anchorScrollIntoId: 'msg1',
+				enterNoticeVisible: false,
+				enterNoticeNickname: '',
+				enterNoticeTimer: null,
+				msgList: [],
+				likeList: [],
+				// 短时去重:同一条文字消息(如发送端+推送)2 秒内相同指纹只显示一次
+				lastTextMsgFingerprints: []
+			};
+		},
+		created() {
+			setTimeout(() => {
+				this.msgList.push({
+					form: {
+						id: 0,
+						nickname: 'Tips'
+					},
+					text: 'Beware of being deceived!'
+				});
+			}, 500);
+			// #ifndef APP
+			setInterval(() => {
+				this.likeStart();
+			}, 300);
+			// #endif
+		},
+		methods: {
+			// 测试使用
+			likeStart() {
+				var b = Math.floor(Math.random() * 6) + 1,
+					bl = Math.floor(Math.random() * 11) + 1;
+				this.likeList.push({class: `bubble b${b} bl${bl}`});
+			},
+			onMessage(msg) {
+				var message = msg.message;
+				var form = msg.form;
+				if (message.type == 'msg' || message.type == 'seek' || message.type == 'follow' || message.type =='like' || message.type == 'review') {
+					if(message.type =='like'){
+						this.likeStart();
+					}
+					// 文字类消息短时去重,避免同一句显示多条(如重复监听或推送重复)
+					const textFp = `${(form && form.id) || ''}|${message.type}|${(message.text || '').slice(0, 200)}`;
+					const now = Date.now();
+					const recent = this.lastTextMsgFingerprints.filter(e => now - e.t < 2000);
+					if (recent.some(e => e.fp === textFp)) {
+						return;
+					}
+					this.lastTextMsgFingerprints = recent.concat([{ fp: textFp, t: now }]).slice(-50);
+
+					this.msgList.push({
+						form: form,
+						text: message.text
+					});
+					this.$nextTick(() => {
+						this.anchorScrollIntoId = `msg${this.msgList.length - 1}`;
+					});
+				}else if (message.type == 'coming') {
+					if (this.enterNoticeVisible) {
+						clearTimeout(this.enterNoticeTimer);
+						this.enterNoticeVisible = false;
+					}
+					this.enterNoticeNickname = form.nickname;
+					this.openComing();
+				}
+			},
+			openComing() {
+				this.enterNoticeVisible = true;
+				this.enterNoticeTimer = setTimeout(() => {
+					this.enterNoticeVisible = false;
+				}, 1000);
+			},
+			iscolor(id) {
+				return ['text-orange', 'text-white', 'text-orange', 'text-yellow', 'text-green', 'text-olive', 'text-green','text-purple', 'text-pink', 'text-pink', 'text-purple'][parseInt(id % 10)]
+			}
+		}
+	};
+</script>
+
+
+<style>
+	@import '@/static/style/wanlnvue.css';
+	.hl88-lr-message {
+		background-color: transparent;
+	}
+	/* 群组消息 */
+	.hl88-lr-comment{
+		/* #ifndef APP */
+		-webkit-mask: -webkit-gradient(linear,left 30%,left top,from(#000),to(transparent));
+		/* #endif */
+		margin-left: 25rpx;
+		justify-content: space-between;
+		flex-direction: row;
+		position: relative;
+	}
+		.hl88-lr-comment-item{
+			overflow: hidden;
+			color: #FFFFFF;
+			flex: 1;
+		}
+			.hl88-lr-comment-item-scroll{
+				/* #ifdef H5 || MP */
+				max-height: 500rpx;
+				min-height: 60rpx;
+				/* #endif */
+				/* #ifdef APP */
+				height: 500rpx;
+				/* #endif */
+			}
+			/* #ifdef H5 || MP */
+			.hl88-lr-comment-item-scroll-h5{
+				margin-top: 6rpx;
+				overflow: hidden;
+				display: flex;
+			}
+			.hl88-lr-comment-item-scroll-mp{
+				margin-bottom: 6rpx;
+				overflow: hidden; 
+				display: block;
+			}
+				.hl88-lr-comment-item-tag{
+					background-color: rgba(0,0,0,.2);
+					padding-top: 8rpx;
+					padding-right: 16rpx;
+					padding-bottom: 8rpx;
+					padding-left: 16rpx;
+					border-radius: 10rpx;
+					overflow: hidden;
+					display: inline-block;
+				}
+			/* #endif */
+			.hl88-lr-comment-item-scroll-app{
+				flex-direction: row;
+				margin-top: 6rpx;
+			}
+				.hl88-lr-comment-item-scroll-app-item{
+					background-color: rgba(0,0,0,.2); 
+					padding-top: 8rpx;
+					padding-right: 16rpx;
+					padding-bottom: 8rpx;
+					padding-left: 16rpx;
+					border-radius: 10rpx;
+					flex-direction: row;
+				}
+	
+		/* 点赞特效	 */
+		.hl88-lr-like{
+			position: relative;
+			/* #ifndef APP */
+			flex: 0 0 120rpx;
+			/* #endif */
+			width: 120rpx; 
+			height: 500rpx;
+		}
+			/* #ifndef APP */
+			.bubble {
+				position: absolute;
+				width: 90rpx;
+				height: 90rpx;
+				bottom: 0;
+				background-repeat: no-repeat;
+				background-size: 100%;
+				transform-origin: bottom;
+			}
+			.b1 {background-image: url(/static/images/live/1.png);}
+			.b2 {background-image: url(/static/images/live/2.png);}
+			.b3 {background-image: url(/static/images/live/3.png);}
+			.b4 {background-image: url(/static/images/live/4.png);}
+			.b5 {background-image: url(/static/images/live/5.png);}
+			.b6 {background-image: url(/static/images/live/6.png);}
+			.bl1 {animation: bubble_1 3s linear 1 forwards,bubble_big_1 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl2 {animation: bubble_2 3s linear 1 forwards,bubble_big_2 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl3 {animation: bubble_3 3s linear 1 forwards,bubble_big_1 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl4 {animation: bubble_4 3s linear 1 forwards,bubble_big_2 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl5 {animation: bubble_5 3s linear 1 forwards,bubble_big_1 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl6 {animation: bubble_6 3s linear 1 forwards,bubble_big_3 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl7 {animation: bubble_7 3s linear 1 forwards,bubble_big_1 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl8 {animation: bubble_8 3s linear 1 forwards,bubble_big_3 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl9 {animation: bubble_9 3s linear 1 forwards,bubble_big_2 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl10 {animation: bubble_10 3s linear 1 forwards,bubble_big_1 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			.bl11 {animation: bubble_11 3s linear 1 forwards,bubble_big_2 0.8s linear 1 forwards,bubble_y 3s linear 1 forwards;}
+			@keyframes bubble_11 {0% {}25% {margin-left: -20rpx;}50% {margin-left: -20rpx;}100% {margin-left: -36rpx;}}
+			@keyframes bubble_10 {0% {}25% {margin-left: -40rpx;}50% {margin-left: -40rpx;}100% {margin-left: -40rpx;}}
+			@keyframes bubble_9 {0% {}25% {margin-left: 20rpx;}50% {margin-left: 20rpx;}100% {margin-left: 20rpx;}}
+			@keyframes bubble_8 {0% {}25% {margin-left: 40rpx;}50% {margin-left: 40rpx;}100% {margin-left: 40rpx;}}
+			@keyframes bubble_7 {0% {}25% {margin-left: 6rpx;}50% {margin-left: 2rpx;}75% {margin-left: 4rpx;}100% {margin-left: 6rpx;}}
+			@keyframes bubble_6 {0% {}25% {margin-left: -6rpx;}50% {margin-left: -2rpx;}75% {margin-left: -4rpx;}100% {margin-left: -6rpx;}}
+			@keyframes bubble_5 {0% {}25% {margin-left: 10rpx;}50% {margin-left: -10rpx;}75% {margin-left: -20rpx;}100% {margin-left: -40rpx;}}
+			@keyframes bubble_4 {0% {}25% {margin-left: -10rpx;}50% {margin-left: -10rpx;}75% {margin-left: 40rpx;}100% {margin-left: 20rpx;}}
+			@keyframes bubble_3 {0% {}25% {margin-left: -40rpx;}50% {margin-left: 20rpx;}75% {margin-left: 40rpx;}100% {margin-left: -20rpx;}}
+			@keyframes bubble_2 {0% {}25% {margin-left: 40rpx;}50% {margin-left: 50rpx;}75% {margin-left: 20rpx;}100% {margin-left: 10rpx;}}
+			@keyframes bubble_1 {0% {}25% {margin-left: -16rpx;}50% {margin-left: 16rpx;}75% {margin-left: -30rpx;}100% {margin-left: 30rpx;}}
+			@keyframes bubble_big_1 {0% {transform: scale(0.3);}100% {transform: scale(1.2);}}
+			@keyframes bubble_big_2 {0% {transform: scale(0.3);}100% {transform: scale(0.9);}}
+			@keyframes bubble_big_3 {0% {transform: scale(0.3);}100% {transform: scale(0.6);}}
+			@keyframes bubble_y {0% {margin-bottom: 0;}10% {margin-bottom: 0;}75% {opacity: 1;}100% {margin-bottom: 400rpx;opacity: 0;}}
+			/* #endif */
+	/* 欢迎信息 */
+	.hl88-lr-coming {
+		margin-left: 25rpx;
+		background-color: rgba(255, 86, 136, .7);
+		padding-top: 8rpx;
+		padding-right: 16rpx;
+		padding-bottom: 8rpx;
+		padding-left: 16rpx;
+		border-radius: 10rpx;
+	}
+		.hl88-lr-coming-text {
+			color: #ffffff;
+			font-size: 28rpx;
+		}
+	.hl88-lr-msgAppNm{
+		font-size: 29rpx;
+	}
+	.hl88-lr-msgAppBody{
+		font-size: 29rpx;
+		color: #ffffff;
+	}
+	.hl88-lr-msgWebNm{
+		font-size: 26rpx;
+	}
+	.hl88-lr-msgWebBody{
+		font-size: 26rpx;
+		color: #ffffff;
+	}
+</style>

+ 58 - 0
components/live-room/play.vue

@@ -0,0 +1,58 @@
+
+<template>
+	<view style="flex: 1;">
+		<!-- #ifndef H5 -->
+		<!-- 基本所有小程序都支持m3u8,但m3u8延迟较高,App使用rtmp -->
+		<video
+			:style="{ height: screenHeight + 'px',width: screenWidth + 'px'}"
+			:src="source"
+			:controls="false"
+			object-fit="contain"
+			loop
+			autoplay
+		></video>
+		<!-- #endif -->
+		<!-- #ifdef H5 -->
+		<!-- 因Flash业务调整,Flash播放已停止更新,很遗憾不能使用rtmp播放,可以选择m3u8、flv -->
+		<player
+			:autoplay="true"
+			:playsinline="true"
+			:source="source"
+			:height="screenHeight + 'px'"
+			:videoHeight="screenHeight + 'px'"
+			:width="screenWidth + 'px'"
+			videoWidth="auto"
+			:skinLayout="skinLayout"
+			:preload="true"
+			:isLive="true"
+		></player>
+		<!-- #endif -->
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'LiveRoomPlay',
+	props: {
+		screenHeight: {
+			default: 0
+		},
+		screenWidth: {
+			default: 0
+		},
+		source: {
+			type: String,
+			default: ''
+		}
+	},
+	data() {
+		return {
+			skinLayout: [{name: "bigPlayButton", align: "blabs", x: this.screenWidth/2 - 30, y: this.screenHeight/2 - 30}]
+		};
+	},
+};
+</script>
+
+<style>
+</style>
+

+ 44 - 0
components/live-room/tip.vue

@@ -0,0 +1,44 @@
+<template>
+	<view class="hl88-lr-tip-banner">
+		<view class="hl88-lr-tip-banner-inner">
+			<text class="hl88-lr-tip-banner-text">{{text}}</text>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		name: 'LiveRoomTip',
+		props: {
+			text: {
+				type: String,
+				default: ''
+			}
+		}
+	}
+</script>
+
+<style>
+	.hl88-lr-tip-banner{
+		position: absolute;
+		left: 0;
+		right: 0;
+		top: 0;
+		bottom: 0;
+		align-items: center;
+		justify-content: center;
+	}
+	.hl88-lr-tip-banner-inner{
+		background-color: rgba(255,100,0,.3);
+		height: 80rpx;
+		padding-right: 50rpx;
+		padding-left: 50rpx;
+		align-items: center;
+		justify-content: center;
+		border-radius: 100px;
+	}
+	.hl88-lr-tip-banner-text{
+		font-size: 32rpx;
+		color: #ffffff;
+	}
+</style>

+ 96 - 0
components/live-watch/anchor-bar.vue

@@ -0,0 +1,96 @@
+<template>
+	<view class="anchor-bar" :style="{ paddingTop: statusBar + 'px' }">
+		<view class="anchor-back" @tap="$emit('back')">
+			<text class="anchor-icon">‹</text>
+		</view>
+		<view class="anchor-card">
+			<image class="anchor-avatar" :src="avatar" mode="aspectFill" />
+			<view class="anchor-meta">
+				<text class="anchor-name">{{ name }}</text>
+				<text class="anchor-online">{{ online }} 人在线</text>
+			</view>
+			<view v-if="showFollow" class="anchor-follow" :class="{ on: followed }" @tap="$emit('follow')">
+				<text>{{ followed ? '已关注' : '关注' }}</text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'AnchorBar',
+	props: {
+		statusBar: { type: Number, default: 0 },
+		name: { type: String, default: '' },
+		avatar: { type: String, default: '' },
+		online: { type: [Number, String], default: 0 },
+		followed: { type: Boolean, default: false },
+		showFollow: { type: Boolean, default: true }
+	}
+}
+</script>
+
+<style scoped>
+.anchor-bar {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	z-index: 20;
+	padding-left: 24rpx;
+	padding-right: 24rpx;
+}
+.anchor-back {
+	width: 72rpx;
+	height: 72rpx;
+	border-radius: 50%;
+	background: rgba(0, 0, 0, 0.35);
+	align-items: center;
+	justify-content: center;
+	margin-bottom: 16rpx;
+}
+.anchor-icon {
+	color: #fff;
+	font-size: 48rpx;
+	line-height: 72rpx;
+	text-align: center;
+}
+.anchor-card {
+	flex-direction: row;
+	align-items: center;
+	background: rgba(0, 0, 0, 0.45);
+	border-radius: 999rpx;
+	padding: 8rpx 20rpx 8rpx 8rpx;
+}
+.anchor-avatar {
+	width: 72rpx;
+	height: 72rpx;
+	border-radius: 50%;
+	margin-right: 16rpx;
+}
+.anchor-meta {
+	flex: 1;
+}
+.anchor-name {
+	color: #fff;
+	font-size: 28rpx;
+	font-weight: 600;
+}
+.anchor-online {
+	color: rgba(255, 255, 255, 0.75);
+	font-size: 22rpx;
+	margin-top: 4rpx;
+}
+.anchor-follow {
+	padding: 12rpx 28rpx;
+	border-radius: 999rpx;
+	background: #ff4d6d;
+}
+.anchor-follow.on {
+	background: rgba(255, 255, 255, 0.2);
+}
+.anchor-follow text {
+	color: #fff;
+	font-size: 24rpx;
+}
+</style>

+ 113 - 0
components/live-watch/chat-dock.vue

@@ -0,0 +1,113 @@
+<template>
+	<view class="chat-dock" :style="{ bottom: safeBottom + 'px' }">
+		<scroll-view scroll-y class="chat-scroll" :scroll-into-view="scrollAnchor">
+			<view
+				v-for="item in lines"
+				:id="'line-' + item.id"
+				:key="item.id"
+				class="chat-row"
+			>
+				<text class="chat-user">{{ item.user }}:</text>
+				<text class="chat-text">{{ item.text }}</text>
+			</view>
+		</scroll-view>
+		<view v-if="interactive" class="chat-input-row">
+			<input
+				v-model="draft"
+				class="chat-input"
+				confirm-type="send"
+				placeholder="说点什么…"
+				placeholder-class="chat-ph"
+				@confirm="submit"
+			/>
+			<view class="chat-like" @tap="$emit('like')">
+				<text>♥ {{ likes }}</text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'ChatDock',
+	props: {
+		lines: { type: Array, default: () => [] },
+		likes: { type: Number, default: 0 },
+		safeBottom: { type: Number, default: 0 },
+		interactive: { type: Boolean, default: true }
+	},
+	data() {
+		return { draft: '' }
+	},
+	computed: {
+		scrollAnchor() {
+			const last = this.lines[this.lines.length - 1]
+			return last ? `line-${last.id}` : ''
+		}
+	},
+	methods: {
+		submit() {
+			const text = (this.draft || '').trim()
+			if (!text) return
+			this.$emit('send', text)
+			this.draft = ''
+		}
+	}
+}
+</script>
+
+<style scoped>
+.chat-dock {
+	position: absolute;
+	left: 24rpx;
+	right: 24rpx;
+	z-index: 15;
+	width: 72%;
+}
+.chat-scroll {
+	max-height: 360rpx;
+	margin-bottom: 16rpx;
+}
+.chat-row {
+	flex-direction: row;
+	flex-wrap: wrap;
+	margin-bottom: 8rpx;
+	padding: 8rpx 16rpx;
+	background: rgba(0, 0, 0, 0.35);
+	border-radius: 12rpx;
+}
+.chat-user {
+	color: #7ec8ff;
+	font-size: 24rpx;
+}
+.chat-text {
+	color: #fff;
+	font-size: 24rpx;
+}
+.chat-input-row {
+	flex-direction: row;
+	align-items: center;
+}
+.chat-input {
+	flex: 1;
+	height: 72rpx;
+	background: rgba(0, 0, 0, 0.45);
+	border-radius: 999rpx;
+	padding: 0 28rpx;
+	color: #fff;
+	font-size: 26rpx;
+}
+.chat-ph {
+	color: rgba(255, 255, 255, 0.6);
+}
+.chat-like {
+	margin-left: 16rpx;
+	padding: 16rpx 24rpx;
+	background: rgba(0, 0, 0, 0.45);
+	border-radius: 999rpx;
+}
+.chat-like text {
+	color: #ff6b8a;
+	font-size: 26rpx;
+}
+</style>

+ 219 - 0
components/live-watch/live-chat-panel.vue

@@ -0,0 +1,219 @@
+<template>
+	<view class="live-chat">
+		<view v-if="enterNoticeVisible" class="live-chat-enter">
+			<text class="live-chat-enter-txt">{{ enterNoticeNickname }} 他来了他来了~</text>
+		</view>
+		<scroll-view
+			scroll-y="true"
+			class="live-chat-scroll"
+			:scroll-into-view="scrollAnchorId"
+			:scroll-with-animation="true"
+			:show-scrollbar="false"
+		>
+			<view
+				v-for="(row, index) in msgList"
+				:key="row.id"
+				:id="'liveMsg' + index"
+				class="live-chat-row"
+			>
+				<view class="live-chat-bubble">
+					<image
+						v-if="row.avatar"
+						class="live-chat-av"
+						:src="rowAvatar(row.avatar)"
+						mode="aspectFill"
+					/>
+					<view v-else class="live-chat-av live-chat-av-fallback">
+						<text class="live-chat-av-letter">{{ avatarLetter(row.user) }}</text>
+					</view>
+					<text class="live-chat-user" :style="{ color: rowColor(row.userId) }">{{ row.user }} : </text>
+					<text class="live-chat-text">{{ row.text }}</text>
+				</view>
+			</view>
+		</scroll-view>
+	</view>
+</template>
+
+<script>
+import appSettings from '@/core/config/app-settings'
+import {
+	appendChatLine,
+	chatLineFingerprint,
+	extractChatLine,
+	extractEnterNotice,
+	isChatDisplayType,
+	nicknameColor
+} from '@/features/live/chat-lines'
+
+const WELCOME_TEXT = 'Beware of being deceived!'
+
+export default {
+	name: 'LiveChatPanel',
+	data() {
+		return {
+			msgList: [],
+			scrollAnchorId: 'liveMsg0',
+			enterNoticeVisible: false,
+			enterNoticeNickname: '',
+			enterNoticeTimer: null,
+			welcomeTimer: null,
+			recentFingerprints: []
+		}
+	},
+	beforeDestroy() {
+		this.clearWelcomeTimer()
+		if (this.enterNoticeTimer) clearTimeout(this.enterNoticeTimer)
+	},
+	methods: {
+		clearWelcomeTimer() {
+			if (this.welcomeTimer) {
+				clearTimeout(this.welcomeTimer)
+				this.welcomeTimer = null
+			}
+		},
+		scheduleWelcomeTip() {
+			this.clearWelcomeTimer()
+			this.welcomeTimer = setTimeout(() => {
+				this.welcomeTimer = null
+				this.pushLocalLine({
+					userId: '0',
+					user: 'Tips',
+					avatar: '',
+					text: WELCOME_TEXT
+				}, true)
+			}, 500)
+		},
+		reset() {
+			this.clearWelcomeTimer()
+			this.msgList = []
+			this.recentFingerprints = []
+			this.enterNoticeVisible = false
+			this.enterNoticeNickname = ''
+			if (this.enterNoticeTimer) clearTimeout(this.enterNoticeTimer)
+			this.scheduleWelcomeTip()
+		},
+		onMessage(msg) {
+			const notice = extractEnterNotice(msg)
+			if (notice) {
+				this.showEnterNotice(notice.nickname)
+			}
+
+			const inner = msg?.message
+			const innerType = inner && typeof inner === 'object' ? inner.type : ''
+			if (innerType && !isChatDisplayType(innerType)) return
+
+			const line = extractChatLine(msg)
+			if (!line) return
+			if (this.isDuplicate(line)) return
+			this.pushLocalLine(line)
+		},
+		pushLocalLine(line, skipDedupe = false) {
+			if (!skipDedupe && line.user === 'Tips' && line.text === WELCOME_TEXT) {
+				const exists = this.msgList.some((row) => row.user === 'Tips' && row.text === WELCOME_TEXT)
+				if (exists) return
+			}
+			this.msgList = appendChatLine(this.msgList, line)
+			this.$nextTick(() => {
+				this.scrollAnchorId = `liveMsg${this.msgList.length - 1}`
+			})
+		},
+		pushOutgoing(line) {
+			if (!line || !line.text) return
+			if (this.isDuplicate(line)) return
+			this.pushLocalLine(line)
+		},
+		isDuplicate(line) {
+			const fp = chatLineFingerprint(line)
+			const now = Date.now()
+			const recent = this.recentFingerprints.filter((item) => now - item.t < 2000)
+			if (recent.some((item) => item.fp === fp)) return true
+			this.recentFingerprints = recent.concat([{ fp, t: now }]).slice(-50)
+			return false
+		},
+		showEnterNotice(nickname) {
+			if (this.enterNoticeTimer) clearTimeout(this.enterNoticeTimer)
+			this.enterNoticeNickname = nickname || '观众'
+			this.enterNoticeVisible = true
+			this.enterNoticeTimer = setTimeout(() => {
+				this.enterNoticeVisible = false
+			}, 1000)
+		},
+		rowAvatar(path) {
+			if (!path) return ''
+			if (/^https?:\/\//.test(path)) return path
+			return `${appSettings.cdnurl}${path}`
+		},
+		rowColor(userId) {
+			return nicknameColor(userId)
+		},
+		avatarLetter(name) {
+			const text = String(name || '?').trim()
+			return text ? text.slice(0, 1).toUpperCase() : '?'
+		}
+	}
+}
+</script>
+
+<style scoped>
+.live-chat {
+	position: relative;
+	width: 560rpx;
+}
+.live-chat-enter {
+	position: absolute;
+	left: 0;
+	top: -72rpx;
+	background-color: rgba(255, 86, 136, 0.72);
+	border-radius: 999rpx;
+	padding-top: 8rpx;
+	padding-bottom: 8rpx;
+	padding-left: 20rpx;
+	padding-right: 20rpx;
+}
+.live-chat-enter-txt {
+	color: #ffffff;
+	font-size: 26rpx;
+}
+.live-chat-scroll {
+	height: 360rpx;
+}
+.live-chat-row {
+	margin-bottom: 12rpx;
+	flex-direction: row;
+}
+.live-chat-bubble {
+	flex-direction: row;
+	align-items: center;
+	background-color: rgba(0, 0, 0, 0.42);
+	border-radius: 999rpx;
+	padding-top: 8rpx;
+	padding-bottom: 8rpx;
+	padding-left: 8rpx;
+	padding-right: 20rpx;
+	max-width: 560rpx;
+}
+.live-chat-av {
+	width: 48rpx;
+	height: 48rpx;
+	border-radius: 24rpx;
+	margin-right: 10rpx;
+}
+.live-chat-av-fallback {
+	background-color: rgba(255, 255, 255, 0.18);
+	justify-content: center;
+	align-items: center;
+}
+.live-chat-av-letter {
+	color: #ffffff;
+	font-size: 22rpx;
+}
+.live-chat-user {
+	font-size: 24rpx;
+	margin-right: 4rpx;
+}
+.live-chat-text {
+	flex: 1;
+	color: #ffffff;
+	font-size: 24rpx;
+}
+</style>

+ 77 - 0
components/live-watch/paywall-mask.vue

@@ -0,0 +1,77 @@
+<template>
+	<view class="paywall">
+		<image v-if="cover" class="paywall-bg" :src="cover" mode="aspectFill" />
+		<view class="paywall-dim" />
+		<view class="paywall-body">
+			<text class="paywall-lock">🔒</text>
+			<text class="paywall-title">试看已结束</text>
+			<text class="paywall-desc">支付 {{ amount }} 钻石继续观看</text>
+			<view class="paywall-btn" @tap="$emit('unlock')">
+				<text>立即解锁</text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'PaywallMask',
+	props: {
+		cover: { type: String, default: '' },
+		amount: { type: [Number, String], default: 0 }
+	}
+}
+</script>
+
+<style scoped>
+.paywall {
+	position: absolute;
+	left: 0;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 30;
+}
+.paywall-bg {
+	position: absolute;
+	width: 100%;
+	height: 100%;
+}
+.paywall-dim {
+	position: absolute;
+	width: 100%;
+	height: 100%;
+	background: rgba(0, 0, 0, 0.55);
+}
+.paywall-body {
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 160rpx;
+	align-items: center;
+	padding: 0 48rpx;
+}
+.paywall-lock {
+	font-size: 80rpx;
+	margin-bottom: 24rpx;
+}
+.paywall-title {
+	color: #fff;
+	font-size: 36rpx;
+	font-weight: 600;
+}
+.paywall-desc {
+	color: rgba(255, 255, 255, 0.85);
+	font-size: 28rpx;
+	margin: 16rpx 0 40rpx;
+}
+.paywall-btn {
+	background: linear-gradient(90deg, #ff6b6b, #ff8e53);
+	padding: 24rpx 80rpx;
+	border-radius: 999rpx;
+}
+.paywall-btn text {
+	color: #fff;
+	font-size: 30rpx;
+}
+</style>

+ 236 - 0
components/live-watch/prism-host.vue

@@ -0,0 +1,236 @@
+<template>
+	<!-- 必须是真正的 <div>(vue 文件根标签),AliPlayer 内部用 querySelector + innerHTML 操作 -->
+	<div class="prism-player" :id="playerId" :style="playStyle"></div>
+</template>
+
+<script>
+/**
+ * AliPlayer H5 容器(仿 MLLive onerizelive-player.vue / SellioRizeLive wanl-player.vue)
+ *
+ * 重要:本文件必须是 .vue 而不是 .nvue,否则 H5 编译后根节点变成 <uni-view>,
+ * AliPlayer 在自定义元素上的 innerHTML / DOM 操作会异常,画面出不来。
+ */
+export default {
+	name: 'PrismHost',
+	props: {
+		source: { type: String, default: '' },
+		height: { type: String, default: '320px' },
+		width: { type: String, default: '100%' },
+		videoWidth: { type: String, default: '100%' },
+		videoHeight: { type: String, default: '320px' },
+		preload: { type: Boolean, default: true },
+		cover: { type: String, default: '' },
+		isLive: { type: Boolean, default: false },
+		autoplay: { type: Boolean, default: true },
+		rePlay: { type: Boolean, default: false },
+		useH5Prism: { type: Boolean, default: true },
+		playsinline: { type: Boolean, default: true },
+		showBuffer: { type: Boolean, default: true },
+		skinLayout: {
+			type: [Array, Boolean],
+			default: function() { return [] }
+		},
+		controlBarVisibility: { type: String, default: 'hover' },
+		format: { type: String, default: 'm3u8' },
+		mediaType: { type: String, default: 'video' },
+		language: { type: String, default: 'zh-cn' },
+		useHlsPluginForSafari: { type: Boolean, default: false },
+		enableStashBufferForFlv: { type: Boolean, default: false },
+		stashInitialSizeForFlv: { type: Number, default: 10 },
+		loadDataTimeout: { type: Number, default: 20 },
+		waitingTimeout: { type: Number, default: 60 },
+		diagnosisButtonVisible: { type: Boolean, default: false },
+		vodRetry: { type: Number, default: 3 },
+		liveRetry: { type: Number, default: 5 },
+		playStyle: { type: String, default: '' },
+		aliplayerSdkPath: {
+			type: String,
+			default: '//g.alicdn.com/de/prismplayer/2.9.3/aliplayer-h5-min.js'
+		}
+	},
+	data() {
+		return {
+			playerId: 'aliplayer_' + Math.random().toString(36).substr(2),
+			scriptTagStatus: 0,
+			instance: null
+		}
+	},
+	created() {
+		if (typeof window === 'undefined') return
+		if (window.Aliplayer !== undefined) {
+			this.scriptTagStatus = 2
+			this.initAliplayer()
+		} else {
+			this.insertScriptTag()
+		}
+	},
+	mounted() {
+		if (typeof window === 'undefined') return
+		if (window.Aliplayer !== undefined) {
+			this.scriptTagStatus = 2
+			this.initAliplayer()
+		} else {
+			this.insertScriptTag()
+		}
+	},
+	beforeDestroy() {
+		if (this.instance) {
+			try { this.instance.dispose() } catch (_) {}
+			this.instance = null
+		}
+	},
+	watch: {
+		source(next, prev) {
+			console.log('[PrismHost] source changed', { prev, next })
+			if (!next) return
+			// AliPlayer 的 loadByUrl 不支持跨格式切换(m3u8 ↔ flv),
+			// 父组件已经通过 :key 强制重挂;这里兜底做完整 dispose+reinit。
+			if (this.instance) {
+				try { this.instance.dispose() } catch (_) {}
+				this.instance = null
+			}
+			this._mutedDone = false
+			this.initAliplayer()
+		}
+	},
+	methods: {
+		insertScriptTag() {
+			const _this = this
+			let tag = document.getElementById('aliplayerScriptTag')
+			if (tag === null) {
+				tag = document.createElement('script')
+				tag.type = 'text/javascript'
+				tag.src = this.aliplayerSdkPath
+				tag.id = 'aliplayerScriptTag'
+				document.getElementsByTagName('head')[0].appendChild(tag)
+			}
+			if (tag.loaded) {
+				_this.scriptTagStatus++
+				_this.initAliplayer()
+			} else {
+				tag.addEventListener('load', () => {
+					_this.scriptTagStatus++
+					tag.loaded = true
+					_this.initAliplayer()
+				})
+			}
+			_this.initAliplayer()
+		},
+		initAliplayer() {
+			const _this = this
+			if (_this.scriptTagStatus < 2) return
+			if (_this.instance) return
+			if (!window.Aliplayer) return
+			console.log('[PrismHost] initAliplayer', { id: _this.playerId, source: _this.source })
+
+			_this.$nextTick(() => {
+				const mount = document.querySelector('#' + _this.playerId)
+				if (!mount) {
+					console.error('[PrismHost] mount node not found')
+					return
+				}
+				mount.innerHTML = ''
+				try {
+					_this.instance = window.Aliplayer({
+						id: _this.playerId,
+						source: _this.source,
+						width: _this.width,
+						height: _this.height,
+						videoWidth: _this.videoWidth,
+						videoHeight: _this.videoHeight,
+						preload: _this.preload,
+						cover: _this.cover,
+						isLive: _this.isLive,
+						autoplay: _this.autoplay,
+						rePlay: _this.rePlay,
+						useH5Prism: _this.useH5Prism,
+						playsinline: _this.playsinline,
+						showBuffer: _this.showBuffer,
+						skinLayout: _this.skinLayout,
+						controlBarVisibility: _this.controlBarVisibility,
+						format: _this.format,
+						mediaType: _this.mediaType,
+						language: _this.language,
+						useHlsPluginForSafari: _this.useHlsPluginForSafari,
+						enableStashBufferForFlv: _this.enableStashBufferForFlv,
+						stashInitialSizeForFlv: _this.stashInitialSizeForFlv,
+						loadDataTimeout: _this.loadDataTimeout,
+						waitingTimeout: _this.waitingTimeout,
+						diagnosisButtonVisible: _this.diagnosisButtonVisible,
+						vodRetry: _this.vodRetry,
+						liveRetry: _this.liveRetry
+					})
+
+					_this.instance.on('ready', () => {
+						console.log('[PrismHost] AliPlayer ready')
+						_this.$emit('ready', _this.instance)
+						_this.applyMutedAutoplay()
+					})
+					_this.instance.on('play', () => {
+						console.log('[PrismHost] AliPlayer play')
+						_this.$emit('play', _this.instance)
+					})
+					_this.instance.on('pause', () => _this.$emit('pause', _this.instance))
+					_this.instance.on('ended', () => _this.$emit('ended', _this.instance))
+					_this.instance.on('liveStreamStop', () => {
+						console.log('[PrismHost] AliPlayer liveStreamStop')
+						_this.$emit('liveStreamStop', _this.instance)
+					})
+					_this.instance.on('m3u8Retry', () => _this.$emit('m3u8Retry', _this.instance))
+					_this.instance.on('waiting', () => console.log('[PrismHost] AliPlayer waiting'))
+					_this.instance.on('error', (e) => {
+						console.error('[PrismHost] AliPlayer ERROR', e)
+						_this.$emit('error', e)
+					})
+
+					setTimeout(() => _this.applyMutedAutoplay(), 600)
+				} catch (err) {
+					console.error('[PrismHost] AliPlayer init FAILED', err && (err.stack || err.message || err))
+				}
+			})
+		},
+		applyMutedAutoplay() {
+			if (this._mutedDone) return
+			const mount = document.querySelector('#' + this.playerId)
+			const vid = mount && mount.querySelector && mount.querySelector('video')
+			if (!vid) return
+			this._mutedDone = true
+			try {
+				vid.muted = true
+				vid.defaultMuted = true
+				vid.setAttribute('muted', 'muted')
+				vid.setAttribute('playsinline', 'true')
+				vid.setAttribute('webkit-playsinline', 'true')
+				vid.setAttribute('x5-playsinline', 'true')
+				const p = vid.play()
+				if (p && p.catch) p.catch((e) => console.warn('[PrismHost] play() rejected', e && e.message))
+			} catch (e) {
+				console.warn('[PrismHost] muted autoplay failed', e)
+			}
+			const unmute = () => {
+				try { vid.muted = false } catch (_) {}
+				document.removeEventListener('click', unmute)
+				document.removeEventListener('touchend', unmute)
+			}
+			document.addEventListener('click', unmute, { once: true })
+			document.addEventListener('touchend', unmute, { once: true })
+		}
+	}
+}
+</script>
+
+<style>
+@import url(//g.alicdn.com/de/prismplayer/2.9.3/skins/default/aliplayer-min.css);
+
+.prism-player {
+	width: 100%;
+	height: 100%;
+	overflow: hidden;
+	background-color: #000;
+}
+.prism-player video {
+	width: 100% !important;
+	height: 100% !important;
+	object-fit: contain;
+}
+</style>

+ 32 - 0
components/live-watch/status-plate.vue

@@ -0,0 +1,32 @@
+<template>
+	<view class="plate">
+		<text class="plate-text">{{ label }}</text>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'StatusPlate',
+	props: {
+		label: { type: String, default: '' }
+	}
+}
+</script>
+
+<style scoped>
+.plate {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	align-items: center;
+	justify-content: center;
+	background: rgba(0, 0, 0, 0.65);
+	z-index: 12;
+}
+.plate-text {
+	color: #fff;
+	font-size: 30rpx;
+}
+</style>

+ 206 - 0
components/live-watch/stream-player-external.nvue

@@ -0,0 +1,206 @@
+<template>
+	<view class="ext-wrap" :style="frameStyle">
+		<!--
+			APP-PLUS 直播渲染器:基于 wrs-uts-videoplayer 的 IjkPlayer 内核。
+			绕过 HBuilderX 5.08+ <video> 切到 ExoPlayer/AVPlayer 不支持 RTMP 的问题。
+
+			只在 nvue/uvue 中可用(标签由 UTS 原生组件提供)。
+		-->
+		<wrs-uts-videoplayer
+			v-if="hasPlugin && hasSource"
+			ref="wrsVideo"
+			class="ext-canvas"
+			:style="frameStyle"
+			:params="paramsStr"
+			@onEvent="onWrsEvent"
+		/>
+		<view v-else-if="!hasPlugin" class="ext-notice">
+			<text class="ext-notice-text">{{ noticeText }}</text>
+		</view>
+		<view v-if="busy" class="ext-loading">
+			<text class="ext-loading-text">{{ busyText }}</text>
+		</view>
+		<view v-if="errorText" class="ext-error">
+			<text class="ext-error-text">{{ errorText }}</text>
+		</view>
+	</view>
+</template>
+
+<script>
+import { hasWrsVideoPlayer } from '@/features/livestream/external-player-detect'
+
+const TAG = '[STREAM-PLAYER:EXT]'
+
+export default {
+	name: 'StreamPlayerExternal',
+	props: {
+		source: { type: String, default: '' },
+		frameWidth: { type: Number, default: 0 },
+		frameHeight: { type: Number, default: 0 },
+		phase: { type: String, default: 'onair' },
+		muted: { type: Boolean, default: false },
+		autoplay: { type: Boolean, default: true }
+	},
+	data() {
+		return {
+			busy: true,
+			busyText: '正在连接直播流…',
+			errorText: ''
+		}
+	},
+	computed: {
+		hasPlugin() {
+			return hasWrsVideoPlayer
+		},
+		hasSource() {
+			return !!this.source
+		},
+		frameStyle() {
+			const w = this.frameWidth ? `${this.frameWidth}px` : '100%'
+			const h = this.frameHeight ? `${this.frameHeight}px` : '100%'
+			return { width: w, height: h }
+		},
+		// 选择 IjkPlayer 内核(这是 RTMP 救急的关键)
+		playerCore() {
+			return 'IjkPlayer'
+		},
+		paramsStr() {
+			const businessArray = [
+				{
+					business: 'setUrl',
+					params: {
+						url: this.source,
+						playerCore: this.playerCore,
+						autoPlay: !!this.autoplay,
+						scaleMode: 'default'
+					}
+				}
+			]
+			if (this.muted) {
+				businessArray.push({ business: 'setVolume', params: { volume: 0 } })
+			}
+			return JSON.stringify({ businessArray })
+		},
+		noticeText() {
+			return [
+				'未检测到 wrs-uts-videoplayer 插件。',
+				'请在插件市场安装(id=16312),并打自定义调试基座。',
+				'集成步骤详见 features/livestream/RESCUE.md。'
+			].join('\n')
+		}
+	},
+	watch: {
+		source(next, prev) {
+			console.log(TAG, 'source changed', { prev, next })
+			if (next && next !== prev) {
+				this.busy = true
+				this.busyText = '正在切换流地址…'
+				this.errorText = ''
+			}
+		}
+	},
+	mounted() {
+		console.log(TAG, 'mounted', {
+			hasPlugin: this.hasPlugin,
+			source: this.source,
+			playerCore: this.playerCore
+		})
+		if (!this.hasPlugin) {
+			this.busy = false
+			this.errorText = '未安装 wrs-uts-videoplayer 插件'
+		}
+	},
+	beforeDestroy() {
+		console.log(TAG, 'beforeDestroy')
+	},
+	methods: {
+		onWrsEvent(e) {
+			const detail = (e && e.detail) || {}
+			const opt = detail.opt || ''
+			console.log(TAG, 'wrs event', opt, detail)
+
+			switch (opt) {
+				case 'onReadyToPlay':
+					this.busy = false
+					this.errorText = ''
+					this.$emit('play', e)
+					break
+				case 'onProgress':
+					// 播放进度心跳;表示流仍在播
+					this.busy = false
+					break
+				case 'onError':
+					this.errorText = '直播流错误(请检查推流是否在线)'
+					this.busy = false
+					this.$emit('error', e)
+					break
+				case 'onComplete':
+					this.errorText = '直播流已结束'
+					this.busy = false
+					this.$emit('ended', e)
+					break
+				default:
+					break
+			}
+		}
+	}
+}
+</script>
+
+<style>
+.ext-wrap {
+	position: absolute;
+	top: 0;
+	left: 0;
+	overflow: hidden;
+	background-color: #000000;
+}
+.ext-canvas {
+	position: absolute;
+	top: 0;
+	left: 0;
+}
+.ext-notice {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	justify-content: center;
+	align-items: center;
+	padding: 0 40rpx;
+}
+.ext-notice-text {
+	color: #ffd66b;
+	font-size: 24rpx;
+	text-align: center;
+	line-height: 40rpx;
+}
+.ext-loading {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	justify-content: center;
+	align-items: center;
+}
+.ext-loading-text {
+	color: #ffffff;
+	font-size: 28rpx;
+}
+.ext-error {
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 80rpx;
+	padding: 0 40rpx;
+	justify-content: center;
+	align-items: center;
+}
+.ext-error-text {
+	color: #ffd66b;
+	font-size: 26rpx;
+	text-align: center;
+}
+</style>

+ 176 - 0
components/live-watch/stream-player.nvue

@@ -0,0 +1,176 @@
+<template>
+	<view class="player-wrap" :style="frameStyle">
+		<!--
+			APP-PLUS 直播播放器(当前:原生 <video> 单档)
+
+			⚠️ wrs-uts-videoplayer(IjkPlayer 内核)尚未购买授权,已临时停用。
+			   购买后请按文件底部"放开 wrs-uts-videoplayer 步骤"重启第三方播放器分档。
+
+			当前行为:
+			  · RTMP / FLV → 原生 <video>(HBuilderX 5.08+ ExoPlayer/AVPlayer,
+			    不支持 RTMP,可能会卡或失败——服务端尽量提供 m3u8)
+			  · m3u8 / mp4 → 原生 <video>(正常)
+		-->
+		<!-- ===== 放开 wrs-uts-videoplayer 后取消下面 6 行注释 ===== -->
+		<!-- <stream-player-external
+			v-if="strategy === 'external' && hasSource"
+			:source="source"
+			:frame-width="frameWidth"
+			:frame-height="frameHeight"
+			:phase="phase"
+			@play="onExtPlay"
+			@buffering="onExtBuffering"
+			@error="onExtError"
+			@ended="onExtEnded"
+		/> -->
+		<video
+			v-if="hasSource"
+			id="livePlayer"
+			ref="livePlayer"
+			class="player-canvas"
+			:style="frameStyle"
+			:src="source"
+			:controls="false"
+			object-fit="contain"
+			:autoplay="true"
+			:loop="!isLivePhase"
+			:show-center-play-btn="false"
+			:show-fullscreen-btn="false"
+			:show-play-btn="false"
+			:show-mute-btn="false"
+			:show-progress="false"
+			:show-loading="false"
+			:enable-progress-gesture="false"
+			:enable-play-gesture="false"
+			:vslide-gesture="false"
+			:auto-pause-if-navigate="false"
+			:auto-pause-if-open-native="false"
+			@play="onVideoPlay"
+			@waiting="onVideoWaiting"
+			@error="onVideoError"
+			@ended="onVideoEnded"
+		/>
+	</view>
+</template>
+
+<script>
+// ===== 放开 wrs-uts-videoplayer 后取消下面 2 行注释 =====
+// import StreamPlayerExternal from './stream-player-external.nvue'
+// import { hasExternalLivePlayer } from '@/features/livestream/external-player-detect'
+
+export default {
+	name: 'StreamPlayerNative',
+	// ===== 放开 wrs-uts-videoplayer 后取消下一行注释 =====
+	// components: { StreamPlayerExternal },
+	props: {
+		source: { type: String, default: '' },
+		frameWidth: { type: Number, default: 0 },
+		frameHeight: { type: Number, default: 0 },
+		phase: { type: String, default: 'onair' }
+	},
+	computed: {
+		frameStyle() {
+			const w = this.frameWidth ? `${this.frameWidth}px` : '100%'
+			const h = this.frameHeight ? `${this.frameHeight}px` : '100%'
+			return { width: w, height: h }
+		},
+		hasSource() {
+			return !!this.source
+		},
+		isLivePhase() {
+			return this.phase === 'onair' || this.phase === 'preparing'
+		},
+		isStreamingProtocol() {
+			const src = String(this.source || '').toLowerCase()
+			if (!src) return false
+			if (src.startsWith('rtmp://') || src.startsWith('rtmps://')) return true
+			if (src.includes('.flv')) return true
+			return false
+		},
+		strategy() {
+			// ===== 放开 wrs-uts-videoplayer 后改回下面这段 =====
+			// if (this.isStreamingProtocol && hasExternalLivePlayer) return 'external'
+			// return 'video'
+			return 'video'
+		}
+	},
+	mounted() {
+		console.log('=== [STREAM-PLAYER:NVUE] mounted ===', {
+			source: this.source,
+			phase: this.phase,
+			strategy: this.strategy
+		})
+	},
+	beforeDestroy() {
+		console.log('=== [STREAM-PLAYER:NVUE] beforeDestroy ===')
+	},
+	watch: {
+		source(next, prev) {
+			console.log('[STREAM-PLAYER:NVUE] source changed', { prev, next, strategy: this.strategy })
+		}
+	},
+	methods: {
+		// ===== 放开 wrs-uts-videoplayer 后取消下面 4 个方法的注释 =====
+		// onExtPlay() {
+		// 	console.log('[STREAM-PLAYER:APP] external play')
+		// },
+		// onExtBuffering() {
+		// 	console.log('[STREAM-PLAYER:APP] external buffering')
+		// },
+		// onExtError(ev) {
+		// 	console.error('[STREAM-PLAYER:APP] external error', ev && (ev.detail || ev))
+		// },
+		// onExtEnded() {
+		// 	console.warn('[STREAM-PLAYER:APP] external ended')
+		// },
+		onVideoPlay() {
+			console.log('[STREAM-PLAYER:APP] <video> play')
+		},
+		onVideoWaiting() {
+			console.warn('[STREAM-PLAYER:APP] <video> WAITING (buffering)')
+		},
+		onVideoError(e) {
+			console.error('[STREAM-PLAYER:APP] <video> ERROR', e && (e.detail || e))
+		},
+		onVideoEnded() {
+			console.warn('[STREAM-PLAYER:APP] <video> ENDED')
+		}
+	}
+}
+
+/**
+ * ============================================================
+ * 放开 wrs-uts-videoplayer 步骤(购买授权后执行)
+ * ============================================================
+ *
+ * 1. 确认 uni_modules/wrs-uts-videoplayer 已正确安装
+ *    (manifest.json 里 app-plus.modules 里包含或自动识别)
+ *
+ * 2. 在本文件取消以下注释块:
+ *    - <template> 顶部 <stream-player-external> 标签
+ *    - <script> 顶部两条 import
+ *    - components: { StreamPlayerExternal }
+ *    - strategy() 计算属性内的原始 if 判断
+ *    - methods 里 onExtPlay / onExtBuffering / onExtError / onExtEnded
+ *
+ * 3. 把 <video> 的 v-if="hasSource" 改回 v-else-if="hasSource"
+ *
+ * 4. 真机测试(推荐先用 pages/dev/ijk-test 测试页验证)。
+ * ============================================================
+ */
+</script>
+
+<style>
+.player-wrap {
+	position: absolute;
+	top: 0;
+	left: 0;
+	overflow: hidden;
+	background-color: #000000;
+}
+.player-canvas {
+	position: absolute;
+	top: 0;
+	left: 0;
+}
+</style>

+ 217 - 0
components/live-watch/stream-player.vue

@@ -0,0 +1,217 @@
+<template>
+	<!-- H5 专用:AliPlayer 包装(prism-host.vue 根节点是真正的 <div class="prism-player">) -->
+	<view class="player-wrap" :style="frameStyle">
+		<prism-host
+			v-if="hasSource"
+			:key="hostKey"
+			:autoplay="true"
+			:playsinline="true"
+			:source="source"
+			:height="hostHeight"
+			:videoHeight="hostHeight"
+			:width="hostWidth"
+			videoWidth="auto"
+			:skinLayout="false"
+			:preload="true"
+			:isLive="true"
+			:format="activeFormat"
+			controlBarVisibility="never"
+			:enableStashBufferForFlv="true"
+			:stashInitialSizeForFlv="4"
+			:loadDataTimeout="10"
+			:waitingTimeout="30"
+			:liveRetry="5"
+			@liveStreamStop="onLiveStop"
+			@error="onAliError"
+			@play="onAliPlay"
+			@ready="onAliReady"
+		/>
+	</view>
+</template>
+
+<script>
+import PrismHost from './prism-host.vue'
+
+function pickAliFormat(url) {
+	const lower = String(url || '').toLowerCase().split('?')[0]
+	if (lower.endsWith('.flv')) return 'flv'
+	if (lower.endsWith('.mp4')) return 'mp4'
+	if (lower.endsWith('.m3u8')) return 'm3u8'
+	return 'm3u8'
+}
+
+const H5_MAX_RETRY = 1
+const H5_RETRY_DELAY_MS = 2000
+// AliPlayer 错误码:4008 缓冲超时 / 4001 网络异常 / 4002 网络断开 /
+// 4006 加载失败 / 4007 网络较差 / 4009 视频加载失败
+// → 通常是 ts 加载失败(直播流刚开播 ts 不足、瞬时抖动、服务器问题)。
+// 给 1 次重试机会兜底;若稳定失败说明是服务器侧问题,前端无法绕过。
+// 4011 移动端不支持 flv/rtmp / 4003 视频解码失败 → 切流也无效,直接报错给上层。
+const H5_RETRY_CODES = new Set([4001, 4002, 4006, 4007, 4008, 4009])
+
+/**
+ * 调试用:主动抓一次 m3u8 内容,把里面 ts 路径打出来。
+ * 失败说明 m3u8 本身就 CORS / 鉴权问题;成功但 ts 404 说明推流断了。
+ */
+function probeM3u8(url) {
+	if (!url) return
+	try {
+		console.log('[STREAM-PLAYER:H5] probing m3u8 ...', url)
+		fetch(url, { credentials: 'omit', cache: 'no-store' })
+			.then(async (r) => {
+				const headers = {}
+				try { r.headers.forEach((v, k) => { headers[k] = v }) } catch (_) {}
+				const text = await r.text().catch(() => '')
+				console.log('[STREAM-PLAYER:H5] m3u8 probe result', {
+					status: r.status,
+					ok: r.ok,
+					cors_origin: headers['access-control-allow-origin'] || '<missing>',
+					content_type: headers['content-type'] || '<missing>',
+					body_len: text.length,
+					body_head: text.slice(0, 600)
+				})
+				const tsLines = text.split(/\r?\n/).filter((s) => s && !s.startsWith('#'))
+				console.log('[STREAM-PLAYER:H5] m3u8 ts entries:', tsLines.length, tsLines)
+				if (tsLines.length > 0) {
+					const first = tsLines[0]
+					const tsUrl = /^https?:\/\//i.test(first)
+						? first
+						: url.replace(/[^/]*$/, '') + first
+					console.log('[STREAM-PLAYER:H5] probing first ts ...', tsUrl)
+					fetch(tsUrl, { credentials: 'omit', cache: 'no-store', method: 'GET' })
+						.then((rr) => {
+							const hh = {}
+							try { rr.headers.forEach((v, k) => { hh[k] = v }) } catch (_) {}
+							console.log('[STREAM-PLAYER:H5] ts probe result', {
+								status: rr.status,
+								ok: rr.ok,
+								cors_origin: hh['access-control-allow-origin'] || '<missing>',
+								content_type: hh['content-type'] || '<missing>',
+								content_length: hh['content-length'] || '<missing>'
+							})
+						})
+						.catch((e) => console.error('[STREAM-PLAYER:H5] ts probe FAIL', e && e.message))
+				}
+			})
+			.catch((e) => console.error('[STREAM-PLAYER:H5] m3u8 probe FAIL', e && e.message))
+	} catch (e) {
+		console.error('[STREAM-PLAYER:H5] probe threw', e && e.message)
+	}
+}
+
+export default {
+	name: 'StreamPlayerH5',
+	components: { PrismHost },
+	props: {
+		source: { type: String, default: '' },
+		frameWidth: { type: Number, default: 0 },
+		frameHeight: { type: Number, default: 0 },
+		phase: { type: String, default: 'onair' }
+	},
+	data() {
+		return {
+			hostKey: 0,
+			h5RetryCount: 0,
+			h5RetryTimer: null
+		}
+	},
+	computed: {
+		frameStyle() {
+			const w = this.frameWidth ? `${this.frameWidth}px` : '100%'
+			const h = this.frameHeight ? `${this.frameHeight}px` : '100%'
+			return { width: w, height: h }
+		},
+		hasSource() {
+			return !!this.source
+		},
+		hostWidth() {
+			return this.frameWidth ? `${this.frameWidth}px` : '100%'
+		},
+		hostHeight() {
+			return this.frameHeight ? `${this.frameHeight}px` : '100%'
+		},
+		activeFormat() {
+			return pickAliFormat(this.source)
+		}
+	},
+	watch: {
+		source: {
+			immediate: true,
+			handler(next) {
+				this.cancelH5Retry()
+				this.h5RetryCount = 0
+				this._probeDone = false
+				this.hostKey += 1
+				console.log('[STREAM-PLAYER:H5] source set to', next)
+			}
+		}
+	},
+	mounted() {
+		console.log('=== [STREAM-PLAYER:H5] mounted ===', {
+			source: this.source,
+			phase: this.phase,
+			frameWidth: this.frameWidth,
+			frameHeight: this.frameHeight
+		})
+	},
+	beforeDestroy() {
+		console.log('=== [STREAM-PLAYER:H5] beforeDestroy ===')
+		this.cancelH5Retry()
+	},
+	methods: {
+		onAliReady() {
+			console.log('[STREAM-PLAYER:H5] AliPlayer ready @', this.source)
+		},
+		onAliPlay() {
+			console.log('[STREAM-PLAYER:H5] AliPlayer play @', this.source, ' (retry =', this.h5RetryCount, ')')
+			this.h5RetryCount = 0
+			this.cancelH5Retry()
+		},
+		onLiveStop() {
+			console.log('[STREAM-PLAYER:H5] liveStreamStop bubbled up')
+			this.$emit('liveStop')
+		},
+		onAliError(e) {
+			const code = Number(e && e.paramData && e.paramData.error_code)
+			const msg = e && e.paramData && e.paramData.error_msg
+			console.error('[STREAM-PLAYER:H5] AliPlayer ERROR code=' + code + ' msg=' + msg + ' src=' + this.source + ' retry=' + this.h5RetryCount)
+
+			if (!this._probeDone) {
+				this._probeDone = true
+				probeM3u8(this.source)
+			}
+
+			if (H5_RETRY_CODES.has(code) && this.h5RetryCount < H5_MAX_RETRY) {
+				this.h5RetryCount += 1
+				console.warn('[STREAM-PLAYER:H5] scheduling m3u8 retry #' + this.h5RetryCount + ' in ' + H5_RETRY_DELAY_MS + 'ms')
+				this.cancelH5Retry()
+				this.h5RetryTimer = setTimeout(() => {
+					this.h5RetryTimer = null
+					console.warn('[STREAM-PLAYER:H5] retry #' + this.h5RetryCount + ' remount AliPlayer')
+					this.hostKey += 1
+				}, H5_RETRY_DELAY_MS)
+				return
+			}
+
+			console.error('[STREAM-PLAYER:H5] giving up after ' + this.h5RetryCount + ' retries, error bubbles to parent')
+			this.$emit('error', e)
+		},
+		cancelH5Retry() {
+			if (this.h5RetryTimer) {
+				clearTimeout(this.h5RetryTimer)
+				this.h5RetryTimer = null
+			}
+		}
+	}
+}
+</script>
+
+<style>
+.player-wrap {
+	position: absolute;
+	top: 0;
+	left: 0;
+	overflow: hidden;
+	background-color: #000000;
+}
+</style>

+ 47 - 0
components/live-watch/stream-view.vue

@@ -0,0 +1,47 @@
+<template>
+	<view class="stream-root">
+		<video
+			v-if="src"
+			class="stream-video"
+			:src="src"
+			:controls="false"
+			:autoplay="true"
+			:show-center-play-btn="false"
+			object-fit="cover"
+			:enable-progress-gesture="false"
+			@error="onError"
+		/>
+		<image v-else-if="poster" class="stream-poster" :src="poster" mode="aspectFill" />
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'StreamView',
+	props: {
+		src: { type: String, default: '' },
+		poster: { type: String, default: '' }
+	},
+	methods: {
+		onError(e) {
+			this.$emit('fault', e)
+		}
+	}
+}
+</script>
+
+<style scoped>
+.stream-root {
+	position: absolute;
+	left: 0;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	background: #000;
+}
+.stream-video,
+.stream-poster {
+	width: 100%;
+	height: 100%;
+}
+</style>

+ 155 - 0
components/mall/mall-bank.vue

@@ -0,0 +1,155 @@
+<template>
+	<view class="bank-item" :style="bankThem || 'background-color: #f40;'">
+		<view class="bank-head">
+			<image :src="image" mode="aspectFit" @error="onImageError"></image>
+			<view class="bank-info">
+				<text class="bank-name">{{bankName}}</text>
+				<text class="card-type">{{cardType}}</text>
+			</view>
+		</view>
+		<view class="card-code">
+			<text class="omit">****</text>
+			<text class="omit">****</text>
+			<text class="omit">****</text>
+			<text>{{endNumber}}</text>
+		</view>
+		<image class="bank-watermark" :src="image" mode="aspectFit"></image>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'mallBank',
+	props: {
+		bankCode: { type: String, required: true },
+		bankName: { type: String, required: true },
+		cardType: { type: String, default: 'Card' },
+		cardCode: { type: String, required: true }
+	},
+	computed: {
+		endNumber() {
+			let length = this.cardCode.length
+			return this.cardCode.substr(length - 4, length)
+		}
+	},
+	data() {
+		return {
+			bankThem: '',
+			image: ''
+		}
+	},
+	created() {
+		this.buildItem()
+	},
+	methods: {
+		onImageError(e) {
+			console.error('[mall-bank] 图片加载失败:', this.image)
+		},
+		resolveBankCode(code) {
+			const nameMap = {
+				'工商银行': 'ICBC', '中國工商銀行': 'ICBC', '工商銀行': 'ICBC',
+				'农业银行': 'ABC', '中國農業銀行': 'ABC', '農業銀行': 'ABC', '農業銀行(香港)': 'ABC',
+				'中国银行': 'BOC', '中國銀行': 'BOC',
+				'建设银行': 'CCB', '中國建設銀行': 'CCB', '建設銀行': 'CCB',
+				'招商银行': 'CMB', '招商銀行': 'CMB',
+				'民生银行': 'CMBC', '中國民生銀行': 'CMBC', '民生銀行': 'CMBC',
+				'交通银行': 'COMM', '交通銀行': 'COMM',
+				'邮储银行': 'PSBC', '郵儲銀行': 'PSBC', '中國郵政儲蓄銀行': 'PSBC',
+				'平安银行': 'PAB', '平安銀行': 'PAB',
+				'浦发银行': 'SPDB', '浦發銀行': 'SPDB', '上海浦東發展銀行': 'SPDB',
+				'广发银行': 'GDB', '廣發銀行': 'GDB',
+				'光大银行': 'CEB', '光大銀行': 'CEB', '中國光大銀行': 'CEB',
+				'兴业银行': 'CIB', '興業銀行': 'CIB', '興業銀行(香港)': 'CIB',
+				'中信银行': 'CITIC', '中信銀行': 'CITIC', '中國中信銀行': 'CITIC',
+				'支付宝': 'ALIPAY', '支付寶': 'ALIPAY',
+				'微信': 'WECHAT', '微信支付': 'WECHAT'
+			}
+			const upper = (code || '').toUpperCase()
+			const englishCodes = ['ICBC','ABC','BOC','CCB','CMB','CMBC','COMM','PSBC','PAB','SPDB','GDB','CEB','CIB','CITIC','ALIPAY','WECHAT']
+			if (englishCodes.includes(upper)) return upper
+			return nameMap[code] || upper
+		},
+		buildItem() {
+			const code = this.resolveBankCode(this.bankCode)
+			this.image = `/static/images/bank/${code}.png`
+			this.bankThem = uni.getStorageSync(`BANK_${code}`)
+			if (this.bankThem) return
+			const colorMap = {
+				ICBC: ['#d4272c', '#b71c1c'], ABC: ['#43a047', '#2e7d32'], BOC: ['#c62828', '#a31515'],
+				CCB: ['#1565c0', '#0d47a1'], CMB: ['#e65100', '#bf360c'], CMBC: ['#00838f', '#006064'],
+				COMM: ['#1b5e20', '#0a3d0a'], PSBC: ['#2e7d32', '#1b5e20'], PAB: ['#f57f17', '#e65100'],
+				SPDB: ['#283593', '#1a237e'], GDB: ['#c62828', '#b71c1c'], CEB: ['#6a1b9a', '#4a148c'],
+				CIB: ['#00695c', '#004d40'], CITIC: ['#d32f2f', '#b71c1c'],
+				ALIPAY: ['#1677ff', '#0958d9'], WECHAT: ['#07c160', '#06ad56']
+			}
+			const colors = colorMap[code] || ['#ff6b35', '#e55a2b']
+			this.bankThem = `background-image: linear-gradient(45deg, ${colors[0]}, ${colors[1]});`
+			uni.setStorageSync(`BANK_${code}`, this.bankThem)
+		}
+	}
+}
+</script>
+
+<style>
+.omit {
+	font-size: 48rpx;
+	margin-right: 30rpx;
+}
+
+.card-code {
+	margin-top: 15rpx;
+	display: flex;
+	justify-content: flex-end;
+	color: #FFFFFF;
+	font-size: 38rpx;
+}
+
+.card-type {
+	font-size: 24rpx;
+	color: #F1F1F1;
+}
+
+.bank-name {
+	font-size: 32rpx;
+	color: #FFFFFF;
+}
+
+.bank-info {
+	display: flex;
+	flex-direction: column;
+	margin-left: 30rpx;
+}
+
+.bank-head {
+	display: flex;
+	flex: 1;
+	align-items: center;
+}
+
+.bank-head image {
+	width: 100rpx;
+	height: 100rpx;
+	padding: 15rpx;
+	background-color: #FFFFFF;
+	border-radius: 50%;
+	overflow: hidden;
+}
+
+.bank-watermark {
+	position: absolute;
+	right: 60rpx;
+	bottom: -30rpx;
+	width: 120rpx;
+	height: 120rpx;
+	filter: grayscale(100%) brightness(400%);
+	opacity: 0.1;
+}
+
+.bank-item {
+	flex: 1;
+	margin-top: 20rpx;
+	position: relative;
+	border-radius: 20rpx;
+	padding: 20rpx;
+}
+</style>

+ 107 - 0
components/mall/mall-category-content.vue

@@ -0,0 +1,107 @@
+<template>
+  <scroll-view class="category-content" :style="{ height: scrollHeight + 'px' }" scroll-y enable-back-to-top @scrolltolower="loadMore">
+    <view v-if="childlist.length > 0" class="sub-menu">
+      <view v-for="item in childlist" :key="item.id" class="sub-item" @tap="goSubCategory(item.id, item.name)">
+        <image :src="item.image" mode="aspectFill" class="sub-img" lazy-load />
+        <text class="sub-name">{{ item.name }}</text>
+      </view>
+    </view>
+    <view class="recommend-title">
+      <view class="recommend-line"></view>
+      <view class="recommend-text">
+        <text class="recommend-icon">★</text>
+        <text class="recommend-label">分類推薦</text>
+      </view>
+      <view class="recommend-line"></view>
+    </view>
+    <mall-product :dataList="goodsList" :dataStyle="dataStyle" />
+    <view class="load-more" v-if="goodsList.length > 0">
+      <text v-if="loading">加載中...</text>
+      <text v-else-if="currentPage >= lastPage">没有更多了</text>
+    </view>
+    <view class="empty" v-if="!goodsList.length && !loading">
+      <text>暫無商品</text>
+    </view>
+  </scroll-view>
+</template>
+
+<script>
+import { pullMallCategoryGoods } from '@/features/mall/mall-gateway'
+export default {
+  name: 'mall-category-content',
+  props: {
+    cid: { type: Number, default: 0 },
+    childlist: { type: Array, default: function () { return [] } },
+    scrollHeight: { type: Number, default: 600 },
+    isActive: { type: Boolean, default: false },
+    dataStyle: { type: String, default: 'col-2-20' }
+  },
+  data: function () {
+    return {
+      goodsList: [],
+      currentPage: 1,
+      lastPage: 1,
+      loading: false,
+      loaded: false
+    }
+  },
+  watch: {
+    isActive: {
+      handler: function (val) {
+        if (val && !this.loaded) { this.loadData() }
+      },
+      immediate: true
+    }
+  },
+  methods: {
+    loadData: function () {
+      var self = this
+      self.loading = true
+      pullMallCategoryGoods({ id: self.cid, page: self.currentPage }).then(function (res) {
+        self.goodsList = res.goods
+        self.currentPage = res.currentPage
+        self.lastPage = res.lastPage
+        self.loaded = true
+      }).catch(function (e) {
+        console.error('加载分类商品失败:', e)
+      }).finally(function () {
+        self.loading = false
+      })
+    },
+    loadMore: function () {
+      if (this.loading || this.currentPage >= this.lastPage) return
+      this.currentPage++
+      this.loading = true
+      var self = this
+      pullMallCategoryGoods({ id: self.cid, page: self.currentPage }).then(function (res) {
+        self.goodsList = self.goodsList.concat(res.goods)
+        self.currentPage = res.currentPage
+        self.lastPage = res.lastPage
+      }).catch(function (e) {
+        console.error('加载更多失败:', e)
+      }).finally(function () {
+        self.loading = false
+      })
+    },
+    goSubCategory: function (id, name) {
+      var data = JSON.stringify({ category_id: id, category_name: name })
+      uni.navigateTo({ url: '/pages/product/list?data=' + encodeURIComponent(data) })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.category-content { background: #f5f5f5; }
+.sub-menu { display: grid; grid-template-columns: repeat(5, 1fr); grid-gap: 16rpx; background: #fff; padding: 24rpx; margin: 16rpx; border-radius: 16rpx; }
+.sub-item { display: flex; flex-direction: column; align-items: center; justify-content: center; }
+.sub-img { width: 100rpx; height: 100rpx; border-radius: 8rpx; }
+.sub-name { font-size: 22rpx; color: #666; margin-top: 8rpx; }
+.recommend-title { display: flex; align-items: center; justify-content: center; padding: 30rpx 0 10rpx; }
+.recommend-line { width: 60rpx; height: 2rpx; background: #ddd; }
+.recommend-text { display: flex; align-items: center; margin: 0 40rpx; }
+.recommend-icon { font-size: 36rpx; color: #f37b1d; margin-right: 8rpx; }
+.recommend-label { font-size: 26rpx; color: #999; font-weight: bold; }
+.load-more { text-align: center; padding: 20rpx 0; font-size: 24rpx; color: #999; }
+.empty { text-align: center; padding: 100rpx 0; color: #999; }
+</style>

+ 263 - 0
components/mall/mall-home-content.vue

@@ -0,0 +1,263 @@
+<template>
+  <scroll-view class="home-content" :style="{ height: scrollHeight + 'px' }" scroll-y enable-back-to-top
+    @scrolltolower="handleLower">
+
+    <view v-if="homeModules && homeModules.item && homeModules.item.length > 0">
+      <view v-for="(item, index) in homeModules.item" :key="index">
+
+        <view v-if="item.type === 'banner'" class="module-banner" :style="[item.style]">
+          <swiper class="banner-swiper" :style="{ height: item.params.height || '300rpx' }" :indicator-dots="true"
+            :circular="true" :autoplay="true" :interval="Number(item.params.interval || 3000)" duration="500">
+            <swiper-item v-for="(img, idx) in item.data" :key="idx" @tap="onLinkTap(img.link)">
+              <image :src="cdn(img.image)" mode="aspectFill" class="banner-img" />
+            </swiper-item>
+          </swiper>
+        </view>
+
+        <view v-if="item.type === 'menu'" class="module-menu" :style="[item.style]">
+          <view class="menu-grid">
+            <view class="menu-item" v-for="(m, idx) in item.data" :key="idx" @tap="onLinkTap(m.link)">
+              <image :src="cdn(m.image)" mode="aspectFill" class="menu-img" />
+              <text class="menu-text">{{ m.text }}</text>
+            </view>
+          </view>
+        </view>
+
+        <view v-if="item.type === 'notice'" class="module-notice" :style="[item.style]">
+          <swiper class="notice-swiper" vertical autoplay circular interval="3000" :style="{ height: '60rpx' }">
+            <swiper-item v-for="(n, idx) in item.data" :key="idx">
+              <text class="notice-text">{{ n.text }}</text>
+            </swiper-item>
+          </swiper>
+        </view>
+
+        <view v-if="item.type === 'image'" class="module-image" :style="[item.style]">
+          <image v-for="(img, idx) in item.data" :key="idx" :src="cdn(img.image)" mode="widthFix"
+            class="module-full-img" @tap="onLinkTap(img.link)" />
+        </view>
+
+        <view v-if="item.type === 'likes'" class="module-likes" :style="[item.style]">
+          <mall-product :dataList="likesDataList"
+            :dataStyle="'col-' + (item.params.colthree || 2) + '-' + (item.params.colmargin || 20)" />
+          <view class="load-more" v-if="likesDataList.length > 0">
+            <text v-if="likesLoading">加載中...</text>
+            <text v-else-if="likesCurrentPage >= likesLastPage">没有更多了</text>
+          </view>
+        </view>
+
+        <view v-if="item.type === 'goods'" class="module-goods" :style="[item.style]">
+          <mall-product :dataList="goodsDataList"
+            :dataStyle="'col-' + (item.params.colthree || 2) + '-' + (item.params.colmargin || 20)" />
+        </view>
+
+      </view>
+    </view>
+
+    <view class="empty" v-if="(!homeModules || !homeModules.item || homeModules.item.length === 0) && !likesLoading">
+      <text>暫無內容</text>
+    </view>
+  </scroll-view>
+</template>
+
+<script>
+import { pullMallLikes, pullMallPageGoods } from '@/features/mall/mall-gateway'
+import appSettings from '@/core/config/app-settings'
+
+function cdn(path) {
+  if (!path) return ''
+  if (/^https?:\/\//.test(path)) return path
+  if (path.startsWith('//')) return 'https:' + path
+  var base = (appSettings.cdnurl || '').replace(/\/$/, '')
+  return base + (path.startsWith('/') ? path : '/' + path)
+}
+
+export default {
+  name: 'mall-home-content',
+  props: {
+    scrollHeight: { type: Number, default: 600 },
+    isActive: { type: Boolean, default: false },
+    dataStyle: { type: String, default: 'col-2-20' },
+    homeModules: { type: Object, default: function () { return {} } }
+  },
+  data: function () {
+    return {
+      likesDataList: [],
+      likesCurrentPage: 1,
+      likesLastPage: 1,
+      likesLoading: false,
+      goodsDataList: []
+    }
+  },
+  watch: {
+    isActive: {
+      handler: function (val) {
+        if (val) { this.loadAllModules() }
+      },
+      immediate: true
+    },
+    homeModules: {
+      handler: function () {
+        if (this.isActive) { this.loadAllModules() }
+      },
+      deep: true
+    }
+  },
+  methods: {
+    cdn: cdn,
+    loadAllModules: function () {
+      var items = (this.homeModules && this.homeModules.item) || []
+      for (var i = 0; i < items.length; i++) {
+        if (items[i].type === 'likes' && this.likesDataList.length === 0) {
+          this.loadLikes()
+        }
+        if (items[i].type === 'goods' && this.goodsDataList.length === 0) {
+          this.loadGoods(items[i])
+        }
+      }
+    },
+    loadLikes: function () {
+      var self = this
+      self.likesLoading = true
+      pullMallLikes({ page: self.likesCurrentPage }).then(function (res) {
+        self.likesDataList = res.rows
+        self.likesCurrentPage = res.currentPage
+        self.likesLastPage = res.lastPage
+      }).catch(function (e) {
+        console.error('加载猜你喜欢失败:', e)
+      }).finally(function () {
+        self.likesLoading = false
+      })
+    },
+    loadGoods: function (item) {
+      var self = this
+      var ids = []
+      var data = item.data || []
+      for (var i = 0; i < data.length; i++) {
+        if (data[i].goodsLink) ids.push(data[i].goodsLink)
+      }
+      if (ids.length === 0) return
+      pullMallPageGoods({ ids: ids.join(',') }).then(function (res) {
+        self.goodsDataList = res
+      }).catch(function (e) {
+        console.error('加载商品模块失败:', e)
+      })
+    },
+    handleLower: function () {
+      if (this.likesLoading || this.likesCurrentPage >= this.likesLastPage) return
+      this.likesCurrentPage++
+      this.likesLoading = true
+      var self = this
+      pullMallLikes({ page: self.likesCurrentPage }).then(function (res) {
+        self.likesDataList = self.likesDataList.concat(res.rows)
+        self.likesCurrentPage = res.currentPage
+        self.likesLastPage = res.lastPage
+      }).catch(function (e) {
+        console.error('加载更多失败:', e)
+      }).finally(function () {
+        self.likesLoading = false
+      })
+    },
+    onLinkTap: function (link) {
+      if (!link) return
+      if (link.startsWith('/pages')) {
+        uni.navigateTo({ url: link })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.home-content {
+  background: #f5f5f5;
+}
+
+.module-banner {
+  width: 100%;
+}
+
+.banner-swiper {
+  width: 100%;
+  min-height: 200rpx;
+}
+
+.banner-img {
+  width: 100%;
+  height: 100%;
+}
+
+.module-menu {
+  padding: 20rpx 0;
+  background: #fff;
+}
+
+.menu-grid {
+  display: flex;
+  flex-wrap: wrap;
+}
+
+.menu-item {
+  width: 20%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+
+.menu-img {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 50%;
+  margin-bottom: 8rpx;
+}
+
+.menu-text {
+  font-size: 22rpx;
+  color: #666;
+}
+
+.module-notice {
+  padding: 10rpx 24rpx;
+  background: #fffbe8;
+}
+
+.notice-swiper {
+  width: 100%;
+}
+
+.notice-text {
+  font-size: 26rpx;
+  color: #e6a23c;
+  line-height: 60rpx;
+}
+
+.module-image {
+  width: 100%;
+}
+
+.module-full-img {
+  width: 100%;
+  display: block;
+}
+
+.module-likes {
+  background: #f5f5f5;
+}
+
+.module-goods {
+  background: #f5f5f5;
+}
+
+.load-more {
+  text-align: center;
+  padding: 20rpx 0;
+  font-size: 24rpx;
+  color: #999;
+}
+
+.empty {
+  text-align: center;
+  padding: 100rpx 0;
+  color: #999;
+}
+</style>

+ 214 - 0
components/mall/mall-navbar.vue

@@ -0,0 +1,214 @@
+<template>
+	<view>
+		<view v-if="fixed" :style="{ height: totalHeight + 'px' }"></view>
+		<view class="mall-navbar" :style="navbarStyle">
+			<view :style="{ height: statusBarHeight + 'px' }"></view>
+			<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
+				<view class="nav-left" @tap="handleBack">
+					<text v-if="isBack && pagesNum > 1" class="back-icon">‹</text>
+					<text v-else-if="isBack" class="home-icon">⌂</text>
+				</view>
+				<view class="nav-center">
+					<view v-if="showSearch" class="search-box" @tap="$emit('search')">
+						<text class="search-icon">🔍</text>
+						<text class="search-text">{{ searchPlaceholder }}</text>
+						<view v-if="searchTag" class="search-tag">
+							<text>{{ searchTag }}</text>
+							<text class="search-tag-close" @tap.stop="$emit('clearSearch')">✕</text>
+						</view>
+					</view>
+					<text v-else class="nav-title">{{ title }}</text>
+				</view>
+				<view class="nav-right">
+					<slot name="right">
+						<view v-if="showStyleToggle" class="style-toggle" @tap="$emit('toggleStyle')">
+							<text v-if="currentStyle === 'col-2-20'" class="toggle-icon">☰</text>
+							<text v-else class="toggle-icon">⊞</text>
+						</view>
+					</slot>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'mall-navbar',
+	props: {
+		title: {
+			type: String,
+			default: ''
+		},
+		isBack: {
+			type: Boolean,
+			default: true
+		},
+		showSearch: {
+			type: Boolean,
+			default: false
+		},
+		searchPlaceholder: {
+			type: String,
+			default: '搜索商品'
+		},
+		searchTag: {
+			type: String,
+			default: ''
+		},
+		showStyleToggle: {
+			type: Boolean,
+			default: false
+		},
+		currentStyle: {
+			type: String,
+			default: 'col-2-20'
+		},
+		background: {
+			type: String,
+			default: '#ffffff'
+		},
+		color: {
+			type: String,
+			default: '#333333'
+		},
+		fixed: {
+			type: Boolean,
+			default: true
+		}
+	},
+	data() {
+		return {
+			statusBarHeight: 0,
+			navBarHeight: 44,
+			pagesNum: 1
+		}
+	},
+	computed: {
+		totalHeight() {
+			return this.statusBarHeight + this.navBarHeight
+		},
+		navbarStyle() {
+			return {
+				background: this.background,
+				color: this.color,
+				position: this.fixed ? 'fixed' : 'relative',
+				top: 0,
+				left: 0,
+				right: 0,
+				zIndex: 999
+			}
+		}
+	},
+	created() {
+		const sysInfo = uni.getSystemInfoSync()
+		this.statusBarHeight = sysInfo.statusBarHeight || 0
+		this.pagesNum = getCurrentPages().length
+	},
+	methods: {
+		handleBack() {
+			if (!this.isBack) return
+			if (this.pagesNum > 1) {
+				uni.navigateBack({ delta: 1 })
+			} else {
+				uni.switchTab({ url: '/pages/index/index' })
+			}
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.mall-navbar {
+	width: 100%;
+}
+.nav-bar {
+	display: flex;
+	align-items: center;
+}
+.nav-left {
+	width: 80rpx;
+	height: 100%;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	flex-shrink: 0;
+}
+.back-icon {
+	font-size: 48rpx;
+	font-weight: bold;
+	color: #333;
+	line-height: 1;
+}
+.home-icon {
+	font-size: 44rpx;
+	color: #333;
+	line-height: 1;
+}
+.nav-center {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	overflow: hidden;
+}
+.nav-title {
+	font-size: 36rpx;
+	font-weight: bold;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.search-box {
+	flex: 1;
+	height: 64rpx;
+	background: #f5f5f5;
+	border-radius: 32rpx;
+	display: flex;
+	align-items: center;
+	padding: 0 24rpx;
+}
+.search-icon {
+	font-size: 26rpx;
+	margin-right: 10rpx;
+	flex-shrink: 0;
+}
+.search-text {
+	font-size: 26rpx;
+	color: #999;
+}
+.search-tag {
+	display: flex;
+	align-items: center;
+	background: #e8e8e8;
+	border-radius: 20rpx;
+	padding: 2rpx 12rpx;
+	margin-left: 10rpx;
+}
+.search-tag text {
+	font-size: 22rpx;
+	color: #666;
+}
+.search-tag-close {
+	margin-left: 6rpx;
+	font-size: 20rpx;
+	color: #999;
+}
+.nav-right {
+	width: 80rpx;
+	height: 100%;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	flex-shrink: 0;
+}
+.style-toggle {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.toggle-icon {
+	font-size: 40rpx;
+	color: #333;
+}
+</style>

+ 225 - 0
components/mall/mall-product.vue

@@ -0,0 +1,225 @@
+<template>
+	<view>
+		<view class="wanl-product">
+			<view class="product_warter" :class="dataStyle"
+				v-if="isWaterfall">
+				<mall-waterfall :flowList="dataList" :addTime="180">
+					<template v-slot:left="{ leftList }">
+						<view class="warter left" v-for="(item, index) in leftList" :key="index"
+							@tap="handleGoods(item.id)">
+							<view class="img-wrap">
+								<image class="image" :src="item.image" mode="widthFix"></image>
+							</view>
+							<view class="content padding-bj">
+								<view class="text-cut-2">
+									<block v-if="item.shop && item.shop.isself == 1">
+										<view class="cu-tag radius sm bg-red">自營</view>
+									</block>
+									<block v-else-if="item.shop">
+										<view class="cu-tag radius sm bg-gray" v-if="item.shop.state == 0">個人</view>
+										<view class="cu-tag radius sm wanl-bg-blue" v-else-if="item.shop.state == 1">企業</view>
+										<view class="cu-tag radius sm wanl-bg-orange" v-else-if="item.shop.state == 2">旗艦</view>
+									</block>
+									<text class="margin-left-xs">{{ item.title }}</text>
+								</view>
+								<view class="goods-tag">
+									<view class="cu-tag radius sm line-red" v-if="item.shop && item.shop.isself == 1">官方保障</view>
+									<view class="cu-tag radius sm line-gray" v-if="item.isLive">直播</view>
+								</view>
+								<view class="flex align-center justify-between">
+									<view class="text-red text-bold text-lg">
+										<text class="text-price">{{ item.price }}</text>
+									</view>
+									<view class="text-sm wanl-gray">
+										<text>{{ formatCount(item.comment) }} 評論</text>
+									</view>
+								</view>
+							</view>
+						</view>
+					</template>
+					<template v-slot:right="{ rightList }">
+						<view class="warter right" v-for="(item, index) in rightList" :key="index"
+							@tap="handleGoods(item.id)">
+							<view class="img-wrap">
+								<image class="image" :src="item.image" mode="widthFix"></image>
+							</view>
+							<view class="content padding-bj">
+								<view class="text-cut-2">
+									<block v-if="item.shop && item.shop.isself == 1">
+										<view class="cu-tag radius sm margin-right-xs bg-red">自營</view>
+									</block>
+									<block v-else-if="item.shop">
+										<view class="cu-tag radius sm margin-right-xs bg-gray" v-if="item.shop.state == 0">個人</view>
+										<view class="cu-tag radius sm margin-right-xs wanl-bg-blue" v-else-if="item.shop.state == 1">企業</view>
+										<view class="cu-tag radius sm margin-right-xs wanl-bg-orange" v-else-if="item.shop.state == 2">旗艦</view>
+									</block>
+									<text>{{ item.title }}</text>
+								</view>
+								<view class="goods-tag">
+									<view class="cu-tag radius sm line-red" v-if="item.shop && item.shop.isself == 1">官方保障</view>
+									<view class="cu-tag radius sm line-gray" v-if="item.isLive">直播</view>
+								</view>
+								<view class="flex align-center justify-between">
+									<view class="text-red text-bold text-lg">
+										<text class="text-price">{{ item.price }}</text>
+									</view>
+									<view class="text-sm wanl-gray">
+										<text>{{ formatCount(item.comment) }} 評論</text>
+									</view>
+								</view>
+							</view>
+						</view>
+					</template>
+				</mall-waterfall>
+			</view>
+			<view class="product_list" :class="dataStyle" v-else>
+				<view class="item" v-for="(item, index) in dataList" :key="index" @tap="handleGoods(item.id)">
+					<view class="img-wrap">
+						<image :src="item.image" mode="aspectFill"></image>
+					</view>
+					<view class="content padding-sm">
+						<view>
+							<view class="text-cut-2">
+								<block v-if="item.shop && item.shop.isself == 1">
+									<view class="cu-tag radius margin-right-xs sm bg-red">自营</view>
+								</block>
+								<block v-else-if="item.shop">
+									<view class="cu-tag radius sm margin-right-xs bg-gray" v-if="item.shop.state == 0">个人</view>
+									<view class="cu-tag radius sm margin-right-xs wanl-bg-blue" v-else-if="item.shop.state == 1">企业</view>
+									<view class="cu-tag radius sm margin-right-xs wanl-bg-orange" v-else-if="item.shop.state == 2">旗舰</view>
+								</block>
+								<text>{{ item.title }}</text>
+							</view>
+							<view class="goods-tag">
+								<view class="cu-tag radius sm line-red" v-if="item.shop && item.shop.isself == 1">官方保障</view>
+								<view class="cu-tag radius sm line-gray" v-if="item.isLive">直播</view>
+							</view>
+						</view>
+						<view class="flex align-center justify-between">
+							<view class="text-red text-bold text-lg">
+								<text class="text-price">{{ item.price }}</text>
+							</view>
+							<view class="text-sm wanl-gray">
+								<text class="margin-right">{{ formatCount(item.comment) }} 评论</text>
+								<text>{{ item.comment > 0 ? parseInt((item.praise / item.comment) * 100) : 0 }}% 好評</text>
+							</view>
+						</view>
+					</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'mall-product',
+	props: {
+		dataList: {
+			type: Array,
+			required: true,
+			default: () => []
+		},
+		dataStyle: {
+			type: String,
+			default: 'col-2-20'
+		}
+	},
+	computed: {
+		isWaterfall() {
+			return ['col-2-10', 'col-2-15', 'col-2-20', 'col-2-25', 'col-2-30'].includes(this.dataStyle)
+		}
+	},
+	methods: {
+		handleGoods(id) {
+			uni.navigateTo({ url: `/pages/product/goods?id=${id}` })
+		},
+		formatCount(num) {
+			if (!num) return 0
+			num = Number(num)
+			if (num >= 10000) return (num / 10000).toFixed(1) + 'w'
+			if (num >= 1000) return (num / 1000).toFixed(1) + 'k'
+			return num
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.wanl-product .content > view { width: 100%; }
+.wanl-product .content .goods-tag { margin-top: 4rpx; margin-bottom: 10rpx; }
+.wanl-product .product_list { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; }
+.wanl-product .product_list .item { border-radius: 16rpx; background-color: #fff; overflow: hidden; }
+.wanl-product .product_list .item .img-wrap,
+.wanl-product .product_list .item image { height: 250rpx; width: 250rpx; }
+.wanl-product .product_warter .warter { border-radius: 16rpx !important; background-color: #ffffff; overflow: hidden; }
+.wanl-product .product_warter .warter .image { border-radius: 16rpx 16rpx 0 0 !important; width: 100%; overflow: hidden; }
+.wanl-product .product_warter .warter .img-wrap { margin-bottom: -4px; }
+.padding-bj { padding: 24rpx; }
+.padding-sm { padding: 16rpx; }
+.text-cut-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-all; }
+.cu-tag { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 6rpx; display: inline-block; vertical-align: middle; color: #fff; line-height: 1.6; }
+.cu-tag.radius { border-radius: 6rpx; }
+.cu-tag.sm { font-size: 18rpx; padding: 1rpx 8rpx; }
+.bg-red { background-color: #e54d42; }
+.bg-gray { background-color: #999; }
+.wanl-bg-blue { background-color: #0081ff; }
+.wanl-bg-orange { background-color: #f37b1d; }
+.line-red { border: 1rpx solid #e54d42; color: #e54d42; background: transparent; }
+.line-gray { border: 1rpx solid #999; color: #999; background: transparent; }
+.text-red { color: #e54d42; }
+.text-bold { font-weight: bold; }
+.text-lg { font-size: 32rpx; }
+.text-sm { font-size: 24rpx; }
+.text-price::before { content: '¥'; font-size: 24rpx; }
+.wanl-gray { color: #999; }
+.margin-left-xs { margin-left: 8rpx; }
+.margin-right-xs { margin-right: 8rpx; }
+.margin-right { margin-right: 12rpx; }
+.flex { display: flex; }
+.align-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.wanl-product .product_warter.col-2-10 { margin: 0 10rpx; padding-top: 10rpx; }
+.wanl-product .product_warter.col-2-10 .warter { margin-bottom: 10rpx; }
+.wanl-product .product_warter.col-2-10 .warter.right { margin-left: 5rpx; }
+.wanl-product .product_warter.col-2-10 .warter.left { margin-right: 5rpx; }
+.wanl-product .product_warter.col-2-15 { margin: 0 15rpx; padding-top: 15rpx; }
+.wanl-product .product_warter.col-2-15 .warter { margin-bottom: 15rpx; }
+.wanl-product .product_warter.col-2-15 .warter.right { margin-left: 7.5rpx; }
+.wanl-product .product_warter.col-2-15 .warter.left { margin-right: 7.5rpx; }
+.wanl-product .product_warter.col-2-20 { margin: 0 20rpx; padding-top: 20rpx; }
+.wanl-product .product_warter.col-2-20 .warter { margin-bottom: 20rpx; }
+.wanl-product .product_warter.col-2-20 .warter.right { margin-left: 10rpx; }
+.wanl-product .product_warter.col-2-20 .warter.left { margin-right: 10rpx; }
+.wanl-product .product_warter.col-2-25 { margin: 0 25rpx; padding-top: 25rpx; }
+.wanl-product .product_warter.col-2-25 .warter { margin-bottom: 25rpx; }
+.wanl-product .product_warter.col-2-25 .warter.right { margin-left: 12.5rpx; }
+.wanl-product .product_warter.col-2-25 .warter.left { margin-right: 12.5rpx; }
+.wanl-product .product_warter.col-2-30 { margin: 0 30rpx; padding-top: 30rpx; }
+.wanl-product .product_warter.col-2-30 .warter { margin-bottom: 30rpx; }
+.wanl-product .product_warter.col-2-30 .warter.right { margin-left: 15rpx; }
+.wanl-product .product_warter.col-2-30 .warter.left { margin-right: 15rpx; }
+.wanl-product .product_list[class*='col-2-'] .item image,
+.wanl-product .product_list[class*='col-2-'] .item .img-wrap { width: 100%; height: 400rpx; }
+.wanl-product .product_list[class*='col-2-'] .content { height: 200rpx; }
+.wanl-product .product_list.col-2-10 { margin: 0 10rpx; padding-top: 10rpx; }
+.wanl-product .product_list.col-2-10 .item { width: calc((100% - 10rpx) / 2); margin-bottom: 10rpx; }
+.wanl-product .product_list.col-2-15 { margin: 0 15rpx; padding-top: 15rpx; }
+.wanl-product .product_list.col-2-15 .item { width: calc((100% - 15rpx) / 2); margin-bottom: 15rpx; }
+.wanl-product .product_list.col-2-20 { margin: 0 20rpx; padding-top: 20rpx; }
+.wanl-product .product_list.col-2-20 .item { width: calc((100% - 20rpx) / 2); margin-bottom: 20rpx; }
+.wanl-product .product_list.col-2-25 { margin: 0 25rpx; padding-top: 25rpx; }
+.wanl-product .product_list.col-2-25 .item { width: calc((100% - 25rpx) / 2); margin-bottom: 25rpx; }
+.wanl-product .product_list.col-2-30 { margin: 0 30rpx; padding-top: 30rpx; }
+.wanl-product .product_list.col-2-30 .item { width: calc((100% - 30rpx) / 2); margin-bottom: 30rpx; }
+.wanl-product .product_list.col-1-10 { margin: 0 10rpx; padding-top: 10rpx; }
+.wanl-product .product_list.col-1-10 .item { display: flex; width: 100%; margin-bottom: 10rpx; }
+.wanl-product .product_list.col-1-15 { margin: 0 15rpx; padding-top: 15rpx; }
+.wanl-product .product_list.col-1-15 .item { display: flex; width: 100%; margin-bottom: 15rpx; }
+.wanl-product .product_list.col-1-20 { margin: 0 20rpx; padding-top: 20rpx; }
+.wanl-product .product_list.col-1-20 .item { display: flex; width: 100%; margin-bottom: 20rpx; }
+.wanl-product .product_list.col-1-25 { margin: 0 25rpx; padding-top: 25rpx; }
+.wanl-product .product_list.col-1-25 .item { display: flex; width: 100%; margin-bottom: 25rpx; }
+.wanl-product .product_list.col-1-30 { margin: 0 30rpx; padding-top: 30rpx; }
+.wanl-product .product_list.col-1-30 .item { display: flex; width: 100%; margin-bottom: 30rpx; }
+</style>

+ 122 - 0
components/mall/mall-sort-bar.vue

@@ -0,0 +1,122 @@
+<template>
+	<view class="sort-bar">
+		<view class="sort-item" :class="{ active: currentIndex === 0 }" @tap="onSort(0)">
+			<text>綜合</text>
+		</view>
+		<view class="sort-item" :class="{ active: currentIndex === 1 }" @tap="onSort(1)">
+			<text>銷量</text>
+		</view>
+		<view class="sort-item" :class="{ active: currentIndex === 2 }" @tap="onSort(2)">
+			<text>新品</text>
+		</view>
+		<view class="sort-item" :class="{ active: currentIndex === 3 }" @tap="onSort(3)">
+			<text>價格</text>
+			<view class="price-arrow">
+				<text class="arrow-up" :class="{ active: priceOrder === 1 && currentIndex === 3 }">▲</text>
+				<text class="arrow-down" :class="{ active: priceOrder === 2 && currentIndex === 3 }">▼</text>
+			</view>
+		</view>
+		<view class="sort-item" @tap="$emit('filter')">
+			<text>篩選</text>
+			<text class="filter-icon">⚡</text>
+		</view>
+	</view>
+</template>
+
+<script>
+const SORT_MAP = [
+	{ sort: 'weigh', order: 'desc' },
+	{ sort: 'sales', order: 'desc' },
+	{ sort: 'createtime', order: 'desc' },
+	{ sort: 'price', order: 'asc' }
+]
+
+export default {
+	name: 'mall-sort-bar',
+	props: {
+		currentSort: {
+			type: String,
+			default: 'weigh'
+		},
+		currentOrder: {
+			type: String,
+			default: 'desc'
+		}
+	},
+	data() {
+		return {
+			currentIndex: 0,
+			priceOrder: 0
+		}
+	},
+	methods: {
+		onSort(index) {
+			if (index === 3) {
+				if (this.currentIndex === 3) {
+					this.priceOrder = this.priceOrder === 1 ? 2 : 1
+				} else {
+					this.priceOrder = 1
+				}
+			} else {
+				this.priceOrder = 0
+			}
+			this.currentIndex = index
+
+			let sort, order
+			if (index === 3) {
+				sort = 'price'
+				order = this.priceOrder === 1 ? 'desc' : 'asc'
+			} else {
+				sort = SORT_MAP[index].sort
+				order = SORT_MAP[index].order
+			}
+			this.$emit('change', { sort, order })
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.sort-bar {
+	display: flex;
+	align-items: center;
+	height: 80rpx;
+	background: #fff;
+	padding: 0 10rpx;
+	border-bottom: 1rpx solid #f0f0f0;
+}
+.sort-item {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	font-size: 26rpx;
+	color: #666;
+	height: 100%;
+}
+.sort-item.active {
+	color: #e54d42;
+	font-weight: bold;
+}
+.price-arrow {
+	display: flex;
+	flex-direction: column;
+	margin-left: 6rpx;
+	line-height: 1;
+}
+.arrow-up, .arrow-down {
+	font-size: 16rpx;
+	color: #ccc;
+	line-height: 1;
+}
+.arrow-up.active {
+	color: #e54d42;
+}
+.arrow-down.active {
+	color: #e54d42;
+}
+.filter-icon {
+	font-size: 22rpx;
+	margin-left: 4rpx;
+}
+</style>

+ 115 - 0
components/mall/mall-waterfall.vue

@@ -0,0 +1,115 @@
+<template>
+	<view class="wanl-waterfall">
+		<view id="wanl-left-cloumn" class="wanl-cloumn">
+			<slot name="left" :leftList="leftList"></slot>
+		</view>
+		<view id="wanl-right-cloumn" class="wanl-cloumn">
+			<slot name="right" :rightList="rightList"></slot>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'mall-waterfall',
+	props: {
+		flowList: {
+			type: Array,
+			required: true,
+			default: () => []
+		},
+		addTime: {
+			type: [Number, String],
+			default: 20
+		}
+	},
+	data() {
+		return {
+			leftList: [],
+			rightList: [],
+			tempList: []
+		}
+	},
+	watch: {
+		copyFlowList(nVal, oVal) {
+			let startIndex = Array.isArray(oVal) && oVal.length > 0 ? oVal.length : 0
+			if (nVal.slice(startIndex).length === 0) {
+				this.leftList = []
+				this.rightList = []
+				this.tempList = this.cloneData(this.copyFlowList)
+			} else {
+				this.tempList = this.tempList.concat(this.cloneData(nVal.slice(startIndex)))
+			}
+			this.splitData()
+		}
+	},
+	mounted() {
+		this.tempList = this.cloneData(this.copyFlowList)
+		this.splitData()
+	},
+	computed: {
+		copyFlowList() {
+			return this.cloneData(this.flowList)
+		}
+	},
+	methods: {
+		async splitData() {
+			if (!this.tempList.length) return
+			let rightRect = await this.getRect('#wanl-right-cloumn')
+			let leftRect = await this.getRect('#wanl-left-cloumn')
+			let item = this.tempList[0]
+			if (!item) return
+			if (leftRect.height < rightRect.height) {
+				this.leftList.push(item)
+			} else if (leftRect.height > rightRect.height) {
+				this.rightList.push(item)
+			} else {
+				if (this.leftList.length <= this.rightList.length) {
+					this.leftList.push(item)
+				} else {
+					this.rightList.push(item)
+				}
+			}
+			this.tempList.splice(0, 1)
+			if (this.tempList.length) {
+				setTimeout(() => { this.splitData() }, this.addTime)
+			}
+		},
+		cloneData(data) {
+			return JSON.parse(JSON.stringify(data))
+		},
+		getRect(selector, all) {
+			return new Promise(resolve => {
+				let query = null
+				// #ifndef MP-ALIPAY
+				query = uni.createSelectorQuery().in(this)
+				// #endif
+				// #ifdef MP-ALIPAY
+				query = uni.createSelectorQuery()
+				// #endif
+				query[all ? 'selectAll' : 'select'](selector)
+					.boundingClientRect(rect => {
+						if (all && Array.isArray(rect) && rect.length) resolve(rect)
+						if (!all && rect) resolve(rect)
+					})
+					.exec()
+			})
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.wanl-waterfall {
+	display: flex;
+	flex-direction: row;
+	align-items: flex-start;
+}
+.wanl-cloumn {
+	display: flex;
+	flex: 1;
+	flex-direction: column;
+	height: auto;
+	width: 50%;
+}
+</style>

+ 379 - 0
components/mine-drawer-shell/mine-drawer-shell.vue

@@ -0,0 +1,379 @@
+<template>
+	<view
+		class="mine-drawer-stage"
+		:class="{ 'mine-drawer-stage--open': open }"
+	>
+		<!-- 中间缝隙(强调左右两块分离) -->
+		<view
+			v-show="open"
+			class="mine-drawer-gutter"
+			:style="gutterStyle"
+		/>
+
+		<!-- 右侧设置层 -->
+		<view
+			class="mine-drawer-panel"
+			:class="{ 'mine-drawer-panel--open': open }"
+			:style="panelLayerStyle"
+		>
+			<slot name="drawer" />
+		</view>
+
+		<!-- 左侧点击关闭热区 -->
+		<view
+			v-show="open"
+			class="mine-drawer-dismiss"
+			:style="dismissStyle"
+			@tap="onDismiss"
+		>
+			<view class="mine-drawer-dismiss-inner">
+				<text class="mine-drawer-dismiss-icon">‹</text>
+			</view>
+			<text class="mine-drawer-dismiss-tip">{{ dismissTip }}</text>
+		</view>
+
+		<!-- 主内容卡片 -->
+		<view
+			class="mine-drawer-main"
+			:class="{ 'mine-drawer-main--open': open }"
+			:style="mainLayerStyle"
+			@touchstart="onMainTouchStart"
+			@touchmove="onMainTouchMove"
+			@touchend="onMainTouchEnd"
+			@touchcancel="onMainTouchEnd"
+		>
+			<slot />
+		</view>
+	</view>
+</template>
+
+<script>
+const MAIN_SCALE = 0.837
+const MAIN_GAP_RPX = 32
+const CARD_INSET_RPX = 28
+/** 左侧预览 : 右侧抽屉 = 2.5 : 7.5(占满屏宽) */
+const PEEK_RATIO = 0.25
+const PANEL_RATIO = 0.75
+/**
+ * 主屏平移:先对齐 25% 分界线,再额外左推 EXTRA,最后收回 BACK(往右)
+ * 40rpx 收回 ≈ 屏宽 40/750;折算后 EXTRA 约 10%
+ */
+const MAIN_EXTRA_SHIFT_RATIO = 0.1
+const MAIN_SHIFT_BACK_RPX = 40
+const CLOSE_DRAG_THRESHOLD = 56
+
+export default {
+	name: 'MineDrawerShell',
+	props: {
+		open: {
+			type: Boolean,
+			default: false
+		},
+		dismissTip: {
+			type: String,
+			default: '返回'
+		}
+	},
+	data() {
+		return {
+			winWidth: 375,
+			panelWidthPx: 300,
+			panelLeftPx: 94,
+			gutterLeftPx: 86,
+			peekWidthPx: 75,
+			gapPx: 16,
+			shiftBackPx: 20,
+			cardInsetPx: 14,
+			safeTop: 0,
+			safeBottom: 0,
+			dragOffset: 0,
+			dragging: false,
+			_touchStartX: 0,
+			_touchStartY: 0,
+			_touchAxis: ''
+		}
+	},
+	computed: {
+		panelLayerStyle() {
+			const base = {
+				paddingTop: `${this.safeTop}px`,
+				paddingBottom: `${this.safeBottom}px`
+			}
+			if (!this.open) {
+				return {
+					...base,
+					width: `${this.panelWidthPx}px`
+				}
+			}
+			return {
+				...base,
+				left: `${this.panelLeftPx}px`,
+				right: `${this.cardInsetPx}px`,
+				width: 'auto',
+				top: `${this.cardInsetPx}px`,
+				bottom: `${this.cardInsetPx}px`,
+				height: 'auto'
+			}
+		},
+		gutterStyle() {
+			return {
+				left: `${this.gutterLeftPx}px`,
+				width: `${this.gapPx}px`,
+				top: `${this.cardInsetPx}px`,
+				bottom: `${this.cardInsetPx}px`
+			}
+		},
+		dismissStyle() {
+			return { width: `${this.peekWidthPx}px` }
+		},
+		mainBaseShiftPx() {
+			return Math.max(this.winWidth - this.peekWidthPx, 0)
+		},
+		mainExtraShiftPx() {
+			return Math.round(this.winWidth * MAIN_EXTRA_SHIFT_RATIO)
+		},
+		mainTotalShiftPx() {
+			const total = this.mainBaseShiftPx + this.mainExtraShiftPx - this.shiftBackPx
+			return Math.max(total, 0)
+		},
+		mainShiftPx() {
+			return this.mainTotalShiftPx
+		},
+		mainLayerStyle() {
+			if (!this.open) return {}
+			const shift = this.mainTotalShiftPx - this.dragOffset
+			const style = {
+				left: '0',
+				right: '0',
+				width: `${this.winWidth}px`,
+				borderRadius: '28rpx',
+				overflow: 'hidden',
+				/* 必须先平移再缩放,否则 translate 在缩放坐标系里几乎不动 */
+				transform: `translate3d(-${shift}px, 0, 0) scale(${MAIN_SCALE})`,
+				transformOrigin: 'right center',
+				top: `${this.cardInsetPx}px`,
+				bottom: `${this.cardInsetPx}px`,
+				height: 'auto',
+				minHeight: '0'
+			}
+			if (this.dragging) {
+				style.transition = 'none'
+			}
+			return style
+		}
+	},
+	watch: {
+		open(val) {
+			if (!val) {
+				this.dragOffset = 0
+				this.dragging = false
+			}
+			this.togglePageScroll(val)
+		}
+	},
+	created() {
+		this.measureViewport()
+		if (typeof uni.onWindowResize === 'function') {
+			uni.onWindowResize(this.measureViewport)
+		}
+	},
+	beforeDestroy() {
+		if (typeof uni.offWindowResize === 'function') {
+			uni.offWindowResize(this.measureViewport)
+		}
+		this.togglePageScroll(false)
+	},
+	methods: {
+		measureViewport() {
+			try {
+				const info = uni.getSystemInfoSync()
+				this.winWidth = info.windowWidth || 375
+				this.safeTop = info.statusBarHeight || 0
+				this.safeBottom = (info.safeAreaInsets && info.safeAreaInsets.bottom) || 0
+				this.gapPx = uni.upx2px(MAIN_GAP_RPX)
+				this.shiftBackPx = uni.upx2px(MAIN_SHIFT_BACK_RPX)
+				this.cardInsetPx = uni.upx2px(CARD_INSET_RPX)
+				this.peekWidthPx = Math.round(this.winWidth * PEEK_RATIO)
+				this.panelLeftPx = this.peekWidthPx
+				this.panelWidthPx = Math.round(this.winWidth * PANEL_RATIO)
+				this.gutterLeftPx = Math.max(this.peekWidthPx - this.gapPx, 0)
+			} catch (_) {}
+		},
+		onDismiss() {
+			if (!this.open) return
+			this.$emit('close')
+		},
+		onMainTouchStart(e) {
+			if (!this.open || !e.touches || !e.touches.length) return
+			this._touchStartX = e.touches[0].clientX
+			this._touchStartY = e.touches[0].clientY
+			this._touchAxis = ''
+			this.dragging = false
+		},
+		onMainTouchMove(e) {
+			if (!this.open || !e.touches || !e.touches.length) return
+			const dx = e.touches[0].clientX - this._touchStartX
+			const dy = e.touches[0].clientY - this._touchStartY
+			if (!this._touchAxis) {
+				if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return
+				this._touchAxis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y'
+			}
+			if (this._touchAxis !== 'x') return
+			if (dx <= 0) {
+				this.dragOffset = 0
+				return
+			}
+			this.dragging = true
+			this.dragOffset = Math.min(dx, this.mainShiftPx)
+		},
+		onMainTouchEnd() {
+			if (!this.open) return
+			if (this.dragOffset >= CLOSE_DRAG_THRESHOLD) {
+				this.$emit('close')
+			}
+			this.dragOffset = 0
+			this.dragging = false
+			this._touchAxis = ''
+		},
+		togglePageScroll(lock) {
+			// #ifdef H5
+			try {
+				document.body.style.overflow = lock ? 'hidden' : ''
+			} catch (_) {}
+			// #endif
+		}
+	}
+}
+</script>
+
+<style scoped>
+.mine-drawer-stage {
+	position: relative;
+	min-height: 100vh;
+	width: 100%;
+	overflow-x: hidden;
+	background-color: #f4f6fb;
+}
+
+.mine-drawer-stage--open {
+	position: fixed;
+	left: 0;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 10050;
+	overflow: hidden;
+	background-color: #d8dee8;
+}
+
+.mine-drawer-gutter {
+	position: fixed;
+	z-index: 4;
+	pointer-events: none;
+	border-radius: 8rpx;
+	background-color: #d8dee8;
+}
+
+.mine-drawer-panel {
+	position: fixed;
+	top: 0;
+	right: 0;
+	height: 100vh;
+	z-index: 2;
+	box-sizing: border-box;
+	display: flex;
+	flex-direction: column;
+	background: #f6f8fc;
+	transform: translateX(100%);
+	opacity: 0;
+	visibility: hidden;
+	pointer-events: none;
+	transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		opacity 0.32s ease,
+		visibility 0.42s,
+		top 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		bottom 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		right 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		border-radius 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		box-shadow 0.42s cubic-bezier(0.32, 0.72, 0, 1);
+	will-change: transform, opacity;
+}
+
+.mine-drawer-panel--open {
+	z-index: 7;
+	transform: translateX(0);
+	opacity: 1;
+	visibility: visible;
+	pointer-events: all;
+	border-radius: 28rpx;
+	border: 2rpx solid #ffffff;
+	overflow: hidden;
+	box-shadow: 0 12rpx 48rpx rgba(15, 23, 42, 0.12);
+}
+
+.mine-drawer-dismiss {
+	position: fixed;
+	left: 0;
+	top: 0;
+	height: 100vh;
+	z-index: 6;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+}
+
+.mine-drawer-dismiss-inner {
+	width: 72rpx;
+	height: 72rpx;
+	border-radius: 50%;
+	background: #ffffff;
+	border: 1rpx solid #e5e7eb;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	box-shadow: 0 6rpx 20rpx rgba(15, 23, 42, 0.08);
+}
+
+.mine-drawer-dismiss-icon {
+	font-size: 44rpx;
+	color: #4b5563;
+	line-height: 1;
+	font-weight: 300;
+	margin-right: 4rpx;
+}
+
+.mine-drawer-dismiss-tip {
+	margin-top: 16rpx;
+	font-size: 22rpx;
+	color: #6b7280;
+	letter-spacing: 2rpx;
+}
+
+.mine-drawer-main {
+	position: relative;
+	z-index: 3;
+	min-height: 100vh;
+	width: 100%;
+	background-color: #f4f6fb;
+	transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		border-radius 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		box-shadow 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		top 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		bottom 0.42s cubic-bezier(0.32, 0.72, 0, 1),
+		height 0.42s cubic-bezier(0.32, 0.72, 0, 1);
+	transform-origin: 100% 50%;
+	will-change: transform;
+}
+
+.mine-drawer-main--open {
+	position: fixed;
+	left: 0;
+	right: 0;
+	z-index: 4;
+	border-radius: 28rpx;
+	border: 2rpx solid #ffffff;
+	overflow: hidden;
+	box-shadow: 0 12rpx 48rpx rgba(15, 23, 42, 0.14);
+}
+</style>

+ 553 - 0
components/mine-side-panel/mine-side-panel.vue

@@ -0,0 +1,553 @@
+<template>
+	<scroll-view class="mine-panel-scroll" scroll-y :show-scrollbar="false">
+		<view class="mine-panel-publish" hover-class="mine-panel-publish--press" @tap="onPublishTap">
+			<image
+				class="mine-panel-publish-icon"
+				src="/static/mine/panel/panel-camera.png"
+				mode="aspectFit"
+			/>
+			<text class="mine-panel-publish-txt">{{ $t('user.panelPublishNow') }}</text>
+		</view>
+
+		<!-- <view
+			v-if="signedIn"
+			class="mine-panel-live"
+			hover-class="mine-panel-live--press"
+			@tap="onLiveBroadcastTap"
+		>
+			<image
+				class="mine-panel-live-icon"
+				src="/static/tabbar/live.png"
+				mode="aspectFit"
+			/>
+			<text class="mine-panel-live-txt">{{ $t('mine.actionLive') }}</text>
+		</view> -->
+
+		<view class="mine-panel-block">
+			<view class="mine-panel-row" hover-class="mine-panel-row--press" @tap.stop="openAccountSecurity">
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-account.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.panelAccountSecurity') }}</text>
+				</view>
+				<text class="mine-panel-chevron">›</text>
+			</view>
+			<view class="mine-panel-row" hover-class="mine-panel-row--press" @tap="openRealnameEntry">
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-identity.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.realnameVerification') }}</text>
+				</view>
+				<view class="mine-panel-row-tail">
+					<text
+						v-if="signedIn && realnameBadgeText"
+						class="mine-panel-badge"
+						:class="{ 'mine-panel-badge--muted': realnameBadgeLoading }"
+					>{{ realnameBadgeText }}</text>
+					<text class="mine-panel-chevron">›</text>
+				</view>
+			</view>
+			<view class="mine-panel-row mine-panel-row--last" hover-class="mine-panel-row--press" @tap="openRoute('/pages/account/language')">
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-language.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.panelLanguage') }}</text>
+				</view>
+				<text class="mine-panel-chevron">›</text>
+			</view>
+		</view>
+
+		<view class="mine-panel-block">
+			<view class="mine-panel-row" hover-class="mine-panel-row--press" @tap="openRoute('/pages/account/notice')">
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-notice.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.panelNewMessage') }}</text>
+				</view>
+				<text class="mine-panel-chevron">›</text>
+			</view>
+			<view class="mine-panel-row mine-panel-row--last" hover-class="mine-panel-row--press" @tap="openRoute('/pages/user/settings/general')">
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-general.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.panelGeneral') }}</text>
+				</view>
+				<text class="mine-panel-chevron">›</text>
+			</view>
+		</view>
+
+		<view class="mine-panel-block">
+			<view class="mine-panel-row" hover-class="mine-panel-row--press" @tap="openRoute('/pages/account/complaint')">
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-report.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.panelReport') }}</text>
+				</view>
+				<text class="mine-panel-chevron">›</text>
+			</view>
+			<view class="mine-panel-row" hover-class="mine-panel-row--press" @tap="openRoute('/pages/account/complaint-lists')">
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-myreports.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.myReports') }}</text>
+				</view>
+				<text class="mine-panel-chevron">›</text>
+			</view>
+			<view class="mine-panel-row mine-panel-row--last" hover-class="mine-panel-row--press" @tap="openRoute('/pages/account/blocked')">
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-blocked.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.blockedList') }}</text>
+				</view>
+				<text class="mine-panel-chevron">›</text>
+			</view>
+		</view>
+
+		<view class="mine-panel-block">
+			<view class="mine-panel-row" hover-class="mine-panel-row--press" @tap="wipeLocalCache">
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-clear.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.panelClearCache') }}</text>
+				</view>
+				<view class="mine-panel-row-tail">
+					<text class="mine-panel-meta">{{ cacheSizeLabel }}</text>
+					<text class="mine-panel-chevron">›</text>
+				</view>
+			</view>
+			<view
+				class="mine-panel-row mine-panel-row--last"
+				hover-class="mine-panel-row--press"
+				@tap="openRoute('/pages/account/about', false, 'pop-in', 200)"
+			>
+				<view class="mine-panel-row-main">
+					<image class="mine-panel-row-icon" src="/static/mine/panel/panel-about.png" mode="aspectFit" />
+					<text class="mine-panel-row-label">{{ $t('user.panelAbout') }}</text>
+				</view>
+				<view class="mine-panel-row-tail">
+					<text class="mine-panel-meta">{{ appVersion }}</text>
+					<text class="mine-panel-chevron">›</text>
+				</view>
+			</view>
+		</view>
+
+		<view class="mine-panel-block mine-panel-block--tail">
+			<view class="mine-panel-row mine-panel-row--logout" hover-class="mine-panel-row--press" @tap="leaveAccount">
+				<view class="mine-panel-row-main">
+					<text class="mine-panel-logout-glyph">⎋</text>
+					<text class="mine-panel-row-label">{{ $t('user.panelLogout') }}</text>
+				</view>
+			</view>
+		</view>
+	</scroll-view>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import { buildRestPath } from '@/core/config/api-paths'
+import { post } from '@/core/http/http-client'
+import {
+	normalizeRealnameFlag,
+	pullRealnameFlagFromApi,
+	realnameBadgeI18nKey,
+	shouldOpenRealnameDetail,
+	REALNAME_NONE
+} from '@/features/mine-panel/realname-bridge'
+
+export default {
+	name: 'MineSidePanel',
+	props: {
+		active: {
+			type: Boolean,
+			default: false
+		}
+	},
+	data() {
+		return {
+			cacheSizeLabel: '0 B',
+			realnameFlag: null,
+			realnameBadgeLoading: false
+		}
+	},
+	computed: {
+		...mapState({
+			storeUser: (state) => state.user,
+			shell: (state) => state.shell
+		}),
+		signedIn() {
+			return !!(this.storeUser && this.storeUser.isLogin)
+		},
+		appVersion() {
+			return (this.shell && this.shell.version) || ''
+		},
+		realnameBadgeText() {
+			if (!this.signedIn) return ''
+			if (this.realnameBadgeLoading) return this.$t('user.realnameSyncing')
+			if (this.realnameFlag === null || this.realnameFlag === undefined) return ''
+			return this.$t(realnameBadgeI18nKey(this.realnameFlag))
+		}
+	},
+	watch: {
+		active(val) {
+			if (val) this.onPanelOpen()
+		},
+		'storeUser.isLogin': {
+			immediate: true,
+			handler(isLogin) {
+				if (isLogin) {
+					this.syncRealnameBadge()
+				} else {
+					this.realnameFlag = null
+					this.realnameBadgeLoading = false
+				}
+			}
+		}
+	},
+	created() {
+		this._onRealnameSync = () => this.syncRealnameBadge()
+		uni.$on('mine-panel-sync-realname', this._onRealnameSync)
+	},
+	beforeDestroy() {
+		if (this._onRealnameSync) {
+			uni.$off('mine-panel-sync-realname', this._onRealnameSync)
+		}
+	},
+	methods: {
+		onPanelOpen() {
+			this.syncRealnameBadge()
+			// #ifdef APP-PLUS
+			this.refreshCacheLabel()
+			// #endif
+		},
+		closePanel() {
+			this.$emit('close')
+		},
+		onPublishTap() {
+			this.closePanel()
+			this.$emit('publish')
+		},
+		onLiveBroadcastTap() {
+			this.closePanel()
+			if (this.$appKit && !this.$appKit.requireLogin()) return
+			uni.navigateTo({
+				url: '/pages/live/broadcast',
+				fail: () => this.$appKit && this.$appKit.to('/pages/live/broadcast')
+			})
+		},
+		openAccountSecurity() {
+			this.closePanel()
+			this.$emit('open-security')
+		},
+		openRoute(url, needAuth = false, anim, delay) {
+			this.closePanel()
+			if (needAuth) {
+				this.$appKit.auth(url)
+				return
+			}
+			const payload = { url }
+			if (anim) {
+				payload.animationType = anim
+				payload.animationDuration = delay != null ? delay : 300
+			}
+			this.$emit('navigate', payload)
+		},
+		async syncRealnameBadge() {
+			if (!this.signedIn) {
+				this.realnameFlag = null
+				this.realnameBadgeLoading = false
+				return
+			}
+			this.realnameBadgeLoading = true
+			const r = await pullRealnameFlagFromApi()
+			this.realnameBadgeLoading = false
+			if (r.ok) {
+				this.realnameFlag = r.is_real
+			} else {
+				this.realnameFlag = null
+			}
+		},
+		async openRealnameEntry() {
+			if (!this.signedIn) {
+				this.closePanel()
+				this.$emit('navigate', { url: '/pages/passport/signin?mode=secret' })
+				return
+			}
+			const r = await pullRealnameFlagFromApi()
+			if (!r.ok) {
+				if (r.reason === 'network') {
+					this.$appKit.msg(this.$t('user.realnameNetworkError'))
+				} else {
+					this.$appKit.msg(this.$t('user.realnameStatusFetchError'))
+				}
+				return
+			}
+			const flag = normalizeRealnameFlag(r.is_real)
+			this.realnameFlag = flag
+			this.closePanel()
+			if (flag === REALNAME_NONE) {
+				this.$emit('navigate', { url: '/pages/account/realname-add' })
+				return
+			}
+			if (shouldOpenRealnameDetail(flag)) {
+				this.$emit('navigate', { url: '/pages/account/realname-info' })
+				return
+			}
+			this.$emit('navigate', { url: '/pages/account/realname-add' })
+		},
+		refreshCacheLabel() {
+			// #ifdef APP-PLUS
+			plus.cache.calculate((size) => {
+				let sizeCache = parseInt(size, 10)
+				if (sizeCache === 0) {
+					this.cacheSizeLabel = '0 B'
+				} else if (sizeCache < 1024) {
+					this.cacheSizeLabel = `${sizeCache} B`
+				} else if (sizeCache < 1048576) {
+					this.cacheSizeLabel = `${(sizeCache / 1024).toFixed(2)} KB`
+				} else if (sizeCache < 1073741824) {
+					this.cacheSizeLabel = `${(sizeCache / 1048576).toFixed(2)} MB`
+				} else {
+					this.cacheSizeLabel = `${(sizeCache / 1073741824).toFixed(2)} GB`
+				}
+			})
+			// #endif
+		},
+		wipeLocalCache() {
+			// #ifndef APP-PLUS
+			this.$appKit.msg(this.$t('user.panelNoCache'))
+			// #endif
+			// #ifdef APP-PLUS
+			uni.showModal({
+				title: this.$t('user.panelPrompt'),
+				content: this.$t('user.panelConfirmClearCache'),
+				success: (res) => {
+					if (!res.confirm) return
+					if (plus.os.name === 'Android') {
+						const main = plus.android.runtimeMainActivity()
+						const sdRoot = main.getCacheDir()
+						const files = plus.android.invoke(sdRoot, 'listFiles')
+						const len = files.length
+						for (let i = 0; i < len; i++) {
+							const filePath = `${files[i]}`
+							plus.io.resolveLocalFileSystemURL(
+								filePath,
+								(entry) => {
+									if (entry.isDirectory) {
+										entry.removeRecursively(() => {
+											this.$appKit.msg(this.$t('user.panelCacheCleared'))
+											this.refreshCacheLabel()
+										})
+									} else {
+										entry.remove()
+									}
+								},
+								() => {}
+							)
+						}
+					} else {
+						plus.cache.clear(() => {
+							this.$appKit.msg(this.$t('user.panelCacheCleared'))
+							this.refreshCacheLabel()
+						})
+					}
+				}
+			})
+			// #endif
+		},
+		async leaveAccount() {
+			if (!this.signedIn) {
+				this.closePanel()
+				this.$appKit.to('/pages/passport/signin')
+				return
+			}
+			uni.showModal({
+				title: this.$t('user.panelPrompt'),
+				content: this.$t('user.panelLogoutConfirm'),
+				success: async (res) => {
+					if (!res.confirm) return
+					try {
+						await post(buildRestPath('user/logout'))
+					} catch (_) {}
+					try {
+						await this.$store.dispatch('im/pause', true)
+					} catch (_) {}
+					await this.$store.dispatch('clearSession')
+					this.$appKit.msg(this.$t('user.panelLogoutOk'))
+					this.closePanel()
+					uni.switchTab({
+						url: '/pages/mine/index',
+						fail: () => {
+							uni.reLaunch({ url: '/pages/mine/index' })
+						}
+					})
+				}
+			})
+		}
+	}
+}
+</script>
+
+<style scoped>
+.mine-panel-scroll {
+	width: 100%;
+	height: 100%;
+	min-height: 100%;
+	box-sizing: border-box;
+	padding: 16rpx 20rpx calc(24rpx + env(safe-area-inset-bottom));
+}
+
+.mine-panel-publish {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	height: 96rpx;
+	margin: 0 8rpx 28rpx;
+	border-radius: 48rpx;
+	background: linear-gradient(90deg, #5ce1e6 0%, #4a8cff 100%);
+	box-shadow: 0 8rpx 24rpx rgba(74, 140, 255, 0.28);
+	transition: transform 0.15s ease, opacity 0.15s ease;
+}
+
+.mine-panel-publish--press {
+	transform: scale(0.98);
+	opacity: 0.92;
+}
+
+.mine-panel-publish-icon {
+	width: 40rpx;
+	height: 40rpx;
+	margin-right: 12rpx;
+}
+
+.mine-panel-publish-txt {
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #ffffff;
+}
+
+.mine-panel-live {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	height: 88rpx;
+	margin: 0 8rpx 28rpx;
+	border-radius: 44rpx;
+	background-color: #ffffff;
+	border: 2rpx solid rgba(74, 140, 255, 0.35);
+	box-shadow: 0 6rpx 20rpx rgba(74, 140, 255, 0.1);
+	transition: transform 0.15s ease, opacity 0.15s ease;
+}
+
+.mine-panel-live--press {
+	transform: scale(0.98);
+	opacity: 0.92;
+}
+
+.mine-panel-live-icon {
+	width: 36rpx;
+	height: 36rpx;
+	margin-right: 12rpx;
+}
+
+.mine-panel-live-txt {
+	font-size: 30rpx;
+	font-weight: 600;
+	color: #4a8cff;
+}
+
+.mine-panel-block {
+	margin-bottom: 20rpx;
+	background: #ffffff;
+	border-radius: 24rpx;
+	overflow: hidden;
+	box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.04);
+}
+
+.mine-panel-block--tail {
+	margin-bottom: 0;
+}
+
+.mine-panel-row {
+	min-height: 108rpx;
+	padding: 0 24rpx 0 28rpx;
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	border-bottom: 1rpx solid #eef0f3;
+	transition: background-color 0.15s ease;
+}
+
+.mine-panel-row--press {
+	background-color: #f3f5f9;
+}
+
+.mine-panel-row--last {
+	border-bottom: none;
+}
+
+.mine-panel-row--logout {
+	border-bottom: none;
+}
+
+.mine-panel-row--logout .mine-panel-row-label {
+	color: #dc2626;
+}
+
+.mine-panel-row-main {
+	display: flex;
+	align-items: center;
+	flex: 1;
+	min-width: 0;
+}
+
+.mine-panel-row-icon {
+	width: 40rpx;
+	height: 40rpx;
+	margin-right: 20rpx;
+	flex-shrink: 0;
+}
+
+.mine-panel-row-label {
+	font-size: 30rpx;
+	color: #111827;
+	font-weight: 500;
+}
+
+.mine-panel-row-tail {
+	display: flex;
+	align-items: center;
+	flex-shrink: 0;
+	margin-left: 12rpx;
+}
+
+.mine-panel-chevron {
+	font-size: 36rpx;
+	color: #c4c9d4;
+	line-height: 1;
+	margin-left: 8rpx;
+}
+
+.mine-panel-badge {
+	font-size: 22rpx;
+	line-height: 1.2;
+	padding: 8rpx 18rpx;
+	border-radius: 999rpx;
+	background: rgba(62, 214, 86, 0.12);
+	color: #2fb04a;
+	max-width: 200rpx;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.mine-panel-badge--muted {
+	background: #eef0f3;
+	color: #6b7280;
+}
+
+.mine-panel-meta {
+	font-size: 24rpx;
+	color: #9ca3af;
+}
+
+.mine-panel-logout-glyph {
+	width: 40rpx;
+	margin-right: 20rpx;
+	flex-shrink: 0;
+	font-size: 34rpx;
+	color: #6b7280;
+	text-align: center;
+	line-height: 1;
+}
+</style>

+ 186 - 0
components/passport-kit/passcode-grid.vue

@@ -0,0 +1,186 @@
+<template>
+	<view class="passcode-grid" :class="'passcode-grid--' + theme" @tap="focusInput">
+		<view
+			v-for="(_, idx) in cells"
+			:key="idx"
+			class="passcode-grid__cell"
+			:class="{
+				'passcode-grid__cell--cursor': focused && idx === cursorAt,
+				'passcode-grid__cell--filled': idx < length,
+				'passcode-grid__cell--alert': alert && !disabled
+			}"
+		>
+			<text v-if="cells[idx]" class="passcode-grid__char">{{ cells[idx] }}</text>
+			<view v-else-if="focused && idx === cursorAt" class="passcode-grid__caret" />
+		</view>
+
+		<input
+			ref="capture"
+			class="passcode-grid__capture"
+			type="number"
+			:value="value"
+			:focus="focused"
+			:maxlength="cellCount"
+			:disabled="disabled"
+			@input="onCapture"
+			@blur="onBlur"
+			@focus="onFocus"
+		/>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'PasscodeGrid',
+	props: {
+		value: { type: String, default: '' },
+		cellCount: { type: Number, default: 6 },
+		disabled: { type: Boolean, default: false },
+		alert: { type: Boolean, default: false },
+		/** dark:通行证深色页;light:账号安全浅色页 */
+		theme: { type: String, default: 'dark' }
+	},
+	data() {
+		return {
+			focused: false
+		}
+	},
+	computed: {
+		length() {
+			return String(this.value || '').length
+		},
+		cursorAt() {
+			return Math.min(this.length, this.cellCount - 1)
+		},
+		cells() {
+			const slots = new Array(this.cellCount).fill('')
+			const seq = String(this.value || '').slice(0, this.cellCount).split('')
+			seq.forEach((ch, i) => {
+				slots[i] = ch
+			})
+			return slots
+		}
+	},
+	methods: {
+		focusInput() {
+			if (this.disabled) return
+			this.focused = true
+			this.$nextTick(() => {
+				const node = this.$refs.capture
+				if (node && typeof node.focus === 'function') node.focus()
+			})
+			this.$emit('focus')
+		},
+		onFocus() {
+			this.focused = true
+		},
+		onBlur() {
+			this.focused = false
+		},
+		onCapture(e) {
+			const raw = String(e.detail.value || '').replace(/\D+/g, '').slice(0, this.cellCount)
+			this.$emit('input', raw)
+			if (raw.length === this.cellCount) this.$emit('settled', raw)
+		}
+	}
+}
+</script>
+
+<style lang="scss">
+.passcode-grid {
+	position: relative;
+	display: flex;
+	justify-content: space-between;
+}
+
+.passcode-grid__cell {
+	width: 78rpx;
+	height: 96rpx;
+	border-radius: 22rpx;
+	border: 2rpx solid rgba(255, 255, 255, 0.22);
+	background: rgba(255, 255, 255, 0.08);
+	box-sizing: border-box;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.passcode-grid--light .passcode-grid__cell {
+	border-color: #e5e9f2;
+	background: #f7f9fc;
+}
+
+.passcode-grid__cell--filled {
+	border-color: rgba(255, 255, 255, 0.38);
+	background: rgba(255, 255, 255, 0.12);
+}
+
+.passcode-grid--light .passcode-grid__cell--filled {
+	border-color: #4a8cff;
+	background: #eef4ff;
+}
+
+.passcode-grid__cell--cursor {
+	border-color: #ffb547;
+	box-shadow: 0 0 24rpx rgba(255, 181, 71, 0.35);
+}
+
+.passcode-grid--light .passcode-grid__cell--cursor {
+	border-color: #4a8cff;
+	box-shadow: 0 0 16rpx rgba(74, 140, 255, 0.25);
+}
+
+.passcode-grid__cell--alert {
+	border-color: #ff4d6a;
+	background: rgba(255, 77, 106, 0.12);
+}
+
+.passcode-grid--light .passcode-grid__cell--alert {
+	border-color: #ef4444;
+	background: #fef2f2;
+}
+
+.passcode-grid__char {
+	font-size: 40rpx;
+	font-weight: 700;
+	color: #fff;
+}
+
+.passcode-grid--light .passcode-grid__char {
+	color: #1a1d26;
+}
+
+.passcode-grid__caret {
+	width: 4rpx;
+	height: 38rpx;
+	background: #ffb547;
+	border-radius: 4rpx;
+	animation: passcodeBlink 1s steps(2, start) infinite;
+}
+
+.passcode-grid--light .passcode-grid__caret {
+	background: #4a8cff;
+}
+
+.passcode-grid__capture {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	width: 100%;
+	height: 100%;
+	opacity: 0;
+	color: transparent;
+	background: transparent;
+	border: 0;
+	caret-color: transparent;
+	z-index: 2;
+}
+
+@keyframes passcodeBlink {
+	0% { opacity: 0; }
+	50% { opacity: 1; }
+	100% { opacity: 0; }
+}
+</style>

+ 221 - 0
components/passport-kit/passport-screen.vue

@@ -0,0 +1,221 @@
+<template>
+	<view class="pp-screen">
+		<view class="pp-screen__sky" />
+		<view class="pp-screen__halo pp-screen__halo--alpha" />
+		<view class="pp-screen__halo pp-screen__halo--beta" />
+		<view class="pp-screen__halo pp-screen__halo--gamma" />
+
+		<view class="pp-screen__bar" :style="{ paddingTop: barTop + 'px', height: barH + 'px' }">
+			<view v-if="canBack" class="pp-screen__back" @tap="emitBack">
+				<text class="pp-screen__back-icon">‹</text>
+			</view>
+			<text class="pp-screen__crumb">{{ crumb }}</text>
+			<view v-if="$slots.actions" class="pp-screen__actions">
+				<slot name="actions" />
+			</view>
+		</view>
+
+		<scroll-view class="pp-screen__scroll" scroll-y :show-scrollbar="false">
+			<view class="pp-screen__hero">
+				<text v-if="kicker" class="pp-screen__kicker">{{ kicker }}</text>
+				<text class="pp-screen__title">{{ title }}</text>
+				<text v-if="subtitle" class="pp-screen__subtitle">{{ subtitle }}</text>
+			</view>
+
+			<view class="pp-screen__card">
+				<slot />
+			</view>
+
+			<view v-if="$slots.footer" class="pp-screen__footer">
+				<slot name="footer" />
+			</view>
+		</scroll-view>
+	</view>
+</template>
+
+<script>
+import { readDeviceLayout } from '@/core/kit/device-metrics'
+
+export default {
+	name: 'PpScreen',
+	props: {
+		title: { type: String, required: true },
+		subtitle: { type: String, default: '' },
+		kicker: { type: String, default: '' },
+		crumb: { type: String, default: '' },
+		canBack: { type: Boolean, default: true }
+	},
+	data() {
+		return { barTop: 0, barH: 96 }
+	},
+	mounted() {
+		const layout = readDeviceLayout()
+		this.barTop = layout.top
+		this.barH = layout.height
+	},
+	methods: {
+		emitBack() {
+			this.$emit('back')
+			// 注意:uni-app 没有 uni.getCurrentPages,必须用全局 getCurrentPages()
+			const stackDepth = getCurrentPages().length
+			if (stackDepth > 1) {
+				uni.navigateBack({ delta: 1 })
+				return
+			}
+			// 栈底兜底:避免按了没反应,回首页 tab
+			uni.switchTab({
+				url: '/pages/index/index',
+				fail: () => uni.reLaunch({ url: '/pages/index/index' })
+			})
+		}
+	}
+}
+</script>
+
+<style lang="scss">
+.pp-screen {
+	position: relative;
+	min-height: 100vh;
+	background: #0e1230;
+	overflow: hidden;
+	padding-bottom: env(safe-area-inset-bottom);
+}
+
+.pp-screen__sky {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	background: linear-gradient(170deg, #1a1f3b 0%, #2a2476 38%, #0e1230 100%);
+}
+
+.pp-screen__halo {
+	position: absolute;
+	border-radius: 50%;
+	filter: blur(110rpx);
+	opacity: 0.55;
+	pointer-events: none;
+}
+
+.pp-screen__halo--alpha {
+	width: 520rpx;
+	height: 520rpx;
+	top: -160rpx;
+	right: -120rpx;
+	background: radial-gradient(circle, rgba(255, 181, 71, 0.5), rgba(255, 181, 71, 0));
+}
+
+.pp-screen__halo--beta {
+	width: 460rpx;
+	height: 460rpx;
+	bottom: 80rpx;
+	left: -160rpx;
+	background: radial-gradient(circle, rgba(61, 90, 254, 0.85), rgba(61, 90, 254, 0));
+}
+
+.pp-screen__halo--gamma {
+	width: 320rpx;
+	height: 320rpx;
+	top: 380rpx;
+	left: 220rpx;
+	opacity: 0.35;
+	background: radial-gradient(circle, rgba(255, 77, 106, 0.7), rgba(255, 77, 106, 0));
+}
+
+.pp-screen__bar {
+	position: relative;
+	z-index: 2;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	padding: 0 28rpx;
+}
+
+.pp-screen__back {
+	position: absolute;
+	left: 16rpx;
+	bottom: 14rpx;
+	width: 64rpx;
+	height: 64rpx;
+	border-radius: 32rpx;
+	background: rgba(255, 255, 255, 0.12);
+	border: 1rpx solid rgba(255, 255, 255, 0.18);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.pp-screen__back-icon {
+	color: #fff;
+	font-size: 44rpx;
+	line-height: 1;
+	font-weight: 600;
+	margin-top: -6rpx;
+}
+
+.pp-screen__crumb {
+	color: rgba(255, 255, 255, 0.7);
+	font-size: 26rpx;
+	letter-spacing: 4rpx;
+}
+
+.pp-screen__actions {
+	position: absolute;
+	right: 24rpx;
+	bottom: 12rpx;
+}
+
+.pp-screen__scroll {
+	position: relative;
+	z-index: 1;
+	height: 100vh;
+	padding: 24rpx 32rpx 80rpx;
+	box-sizing: border-box;
+}
+
+.pp-screen__hero {
+	padding: 48rpx 12rpx 36rpx;
+}
+
+.pp-screen__kicker {
+	display: block;
+	font-size: 24rpx;
+	color: rgba(255, 181, 71, 0.9);
+	letter-spacing: 6rpx;
+	text-transform: uppercase;
+	margin-bottom: 12rpx;
+}
+
+.pp-screen__title {
+	display: block;
+	font-size: 56rpx;
+	color: #fff;
+	font-weight: 700;
+	letter-spacing: 1rpx;
+	line-height: 1.2;
+}
+
+.pp-screen__subtitle {
+	display: block;
+	margin-top: 16rpx;
+	font-size: 26rpx;
+	color: rgba(255, 255, 255, 0.65);
+	line-height: 1.6;
+}
+
+.pp-screen__card {
+	background: rgba(255, 255, 255, 0.06);
+	border: 1rpx solid rgba(255, 255, 255, 0.12);
+	backdrop-filter: blur(20rpx);
+	-webkit-backdrop-filter: blur(20rpx);
+	border-radius: 36rpx;
+	padding: 36rpx 30rpx 40rpx;
+	box-shadow: 0 30rpx 90rpx rgba(8, 8, 32, 0.5);
+}
+
+.pp-screen__footer {
+	margin-top: 36rpx;
+	padding: 0 12rpx 24rpx;
+}
+</style>

+ 135 - 0
components/passport-kit/policy-pledge.vue

@@ -0,0 +1,135 @@
+<template>
+	<view class="pledge">
+		<view class="pledge__row" :class="{ 'pledge__row--shake': shake }">
+			<view class="pledge__tick" :class="{ 'pledge__tick--on': value }" @tap="toggle">
+				<text v-if="value" class="pledge__tick-mark">✓</text>
+			</view>
+			<text class="pledge__text" @tap="toggle">
+				{{ prefix }}
+				<text class="pledge__link" @tap.stop="openTerms">{{ termsLabel }}</text>
+				{{ separator }}
+				<text class="pledge__link" @tap.stop="openPrivacy">{{ privacyLabel }}</text>{{ suffix }}
+			</text>
+		</view>
+	</view>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import { isExternalArticleUrl } from '@/features/article/article-gateway'
+
+export default {
+	name: 'PolicyPledge',
+	props: {
+		value: { type: Boolean, default: false },
+		prefix: { type: String, default: 'I have read and agree to' },
+		separator: { type: String, default: 'and' },
+		termsLabel: { type: String, default: 'Terms of Service' },
+		privacyLabel: { type: String, default: 'Privacy Policy' },
+		suffix: { type: String, default: '' }
+	},
+	data() {
+		return { shake: false }
+	},
+	computed: {
+		...mapState({ appConfig: (state) => state.shell.appConfig })
+	},
+	methods: {
+		toggle() {
+			this.$emit('input', !this.value)
+		},
+		openTerms() {
+			this.openDoc(this.appConfig.user_agreement, this.termsLabel)
+		},
+		openPrivacy() {
+			this.openDoc(this.appConfig.privacy_protection, this.privacyLabel)
+		},
+		openDoc(id, title) {
+			const raw = id != null ? String(id).trim() : ''
+			if (!raw || raw === '0') {
+				const fallback = this.$t ? this.$t('passport.pledge-doc-missing') : 'Document URL not configured'
+				if (this.$appKit && this.$appKit.msg) this.$appKit.msg(fallback)
+				else uni.showToast({ title: fallback, icon: 'none' })
+				return
+			}
+			const encodedTitle = encodeURIComponent(title || '')
+			if (isExternalArticleUrl(raw)) {
+				uni.navigateTo({
+					url: `/pages/article/details?url=${encodeURIComponent(raw)}&title=${encodedTitle}`
+				})
+				return
+			}
+			uni.navigateTo({
+				url: `/pages/article/details?id=${raw}&title=${encodedTitle}`
+			})
+		},
+		flash() {
+			this.shake = true
+			setTimeout(() => {
+				this.shake = false
+			}, 480)
+		}
+	}
+}
+</script>
+
+<style lang="scss">
+.pledge {
+	margin-top: 24rpx;
+}
+
+.pledge__row {
+	display: flex;
+	align-items: flex-start;
+	padding: 12rpx 0;
+}
+
+.pledge__row--shake {
+	animation: pledgeJitter 0.42s ease;
+}
+
+.pledge__tick {
+	width: 36rpx;
+	height: 36rpx;
+	border-radius: 18rpx;
+	border: 2rpx solid rgba(255, 255, 255, 0.35);
+	flex-shrink: 0;
+	margin-right: 16rpx;
+	margin-top: 6rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	background: transparent;
+}
+
+.pledge__tick--on {
+	background: linear-gradient(135deg, #ffb547, #ff4d6a);
+	border-color: transparent;
+}
+
+.pledge__tick-mark {
+	color: #fff;
+	font-size: 24rpx;
+	font-weight: 800;
+	line-height: 1;
+}
+
+.pledge__text {
+	flex: 1;
+	font-size: 24rpx;
+	color: rgba(255, 255, 255, 0.7);
+	line-height: 1.6;
+}
+
+.pledge__link {
+	color: #ffb547;
+}
+
+@keyframes pledgeJitter {
+	0% { transform: translateX(0); }
+	25% { transform: translateX(-8rpx); }
+	50% { transform: translateX(10rpx); }
+	75% { transform: translateX(-6rpx); }
+	100% { transform: translateX(0); }
+}
+</style>

+ 400 - 0
components/passport-kit/region-picker.vue

@@ -0,0 +1,400 @@
+<template>
+	<view>
+		<view class="region-trigger" :class="{ 'region-trigger--locked': disabled }" @tap="open">
+			<text class="region-trigger__flag">{{ flag }}</text>
+			<text class="region-trigger__code">+{{ cleanDial }}</text>
+			<text class="region-trigger__caret">▾</text>
+		</view>
+
+		<view v-if="visible" class="region-overlay">
+			<view class="region-overlay__mask" @tap="close" />
+			<view
+				class="region-sheet"
+				:style="{ paddingBottom: bottomGap + 'px' }"
+				@tap.stop
+			>
+				<view class="region-sheet__top">
+					<view class="region-sheet__handle" />
+					<view class="region-sheet__head">
+						<text class="region-sheet__title">{{ title }}</text>
+						<text class="region-sheet__close" @tap="close">×</text>
+					</view>
+
+					<view class="region-sheet__search">
+						<text class="region-sheet__search-icon">🔎</text>
+						<input
+							v-model="keyword"
+							class="region-sheet__search-input"
+							:placeholder="searchPlaceholder"
+							placeholder-class="region-sheet__placeholder"
+							confirm-type="search"
+							@tap.stop
+						/>
+						<text
+							v-if="keyword"
+							class="region-sheet__search-clear"
+							@tap.stop="keyword = ''"
+						>×</text>
+					</view>
+				</view>
+
+				<scroll-view
+					scroll-y
+					class="region-sheet__list"
+					:style="{ height: listHeight + 'px' }"
+					:show-scrollbar="false"
+				>
+					<view v-if="loading" class="region-sheet__empty">
+						<text class="region-sheet__empty-text">{{ resolvedLoadingText }}</text>
+					</view>
+					<block v-else-if="!keyword">
+						<view v-for="bucket in clusters" :key="bucket.id" class="region-sheet__cluster">
+							<text class="region-sheet__cluster-label">{{ bucket.label }}</text>
+							<view
+								v-for="entry in bucket.rows"
+								:key="entry.slug"
+								class="region-row"
+								:class="{ 'region-row--on': isPicked(entry) }"
+								@tap="pick(entry)"
+							>
+								<text class="region-row__flag">{{ entry.flag }}</text>
+								<text class="region-row__name">{{ rowName(entry) }}</text>
+								<text class="region-row__code">+{{ entry.dial }}</text>
+								<text v-if="isPicked(entry)" class="region-row__on">✓</text>
+							</view>
+						</view>
+					</block>
+
+					<block v-else>
+						<view v-if="matches.length === 0" class="region-sheet__empty">
+							<text class="region-sheet__empty-text">{{ emptyText }}</text>
+						</view>
+						<view
+							v-for="entry in matches"
+							:key="entry.slug"
+							class="region-row"
+							:class="{ 'region-row--on': isPicked(entry) }"
+							@tap="pick(entry)"
+						>
+							<text class="region-row__flag">{{ entry.flag }}</text>
+							<text class="region-row__name">{{ rowName(entry) }}</text>
+							<text class="region-row__code">+{{ entry.dial }}</text>
+							<text v-if="isPicked(entry)" class="region-row__on">✓</text>
+						</view>
+					</block>
+				</scroll-view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import {
+	REGION_ROSTER,
+	clusterRegions,
+	filterRegions,
+	localizeRegion,
+	findRegion,
+	DEFAULT_REGION
+} from '@/features/passport/region-dialcodes'
+import { fetchDialRegions } from '@/features/passport/region-gateway'
+
+export default {
+	name: 'RegionPicker',
+	props: {
+		value: { type: [String, Number], default: DEFAULT_REGION },
+		disabled: { type: Boolean, default: false },
+		title: { type: String, default: 'Select region' },
+		searchPlaceholder: { type: String, default: 'Code, country or area' },
+		emptyText: { type: String, default: 'No matching region found' },
+		loadingText: { type: String, default: '' }
+	},
+	data() {
+		return {
+			visible: false,
+			keyword: '',
+			bottomGap: 0,
+			roster: REGION_ROSTER,
+			loading: false,
+			listHeight: 360
+		}
+	},
+	created() {
+		this.preloadRoster()
+	},
+	computed: {
+		cleanDial() {
+			return String(this.value || '').replace(/^\+/, '') || DEFAULT_REGION
+		},
+		current() {
+			const id = String(this.value || '')
+			const bySlug = this.roster.find((row) => row.slug === id)
+			if (bySlug) return bySlug
+			return findRegion(this.cleanDial, this.roster)
+		},
+		flag() {
+			return this.current ? this.current.flag : '🏳️'
+		},
+		clusters() {
+			return clusterRegions(uni.getLocale(), this.roster)
+		},
+		matches() {
+			return filterRegions(this.keyword, uni.getLocale(), this.roster)
+		},
+		resolvedLoadingText() {
+			if (this.loadingText) return this.loadingText
+			return this.$t('passport.region-loading')
+		}
+	},
+	methods: {
+		preloadRoster() {
+			fetchDialRegions().then((rows) => {
+				if (rows && rows.length) this.roster = rows
+			})
+		},
+		loadRoster() {
+			if (this.loading) return
+			this.loading = true
+			fetchDialRegions()
+				.then((rows) => {
+					if (rows && rows.length) this.roster = rows
+				})
+				.finally(() => {
+					this.loading = false
+				})
+		},
+		open() {
+			if (this.disabled) return
+			this.keyword = ''
+			this.visible = true
+			this.loadRoster()
+			const sys = uni.getSystemInfoSync()
+			const insetBottom = (sys.safeAreaInsets && sys.safeAreaInsets.bottom) || 0
+			this.bottomGap = insetBottom
+			const sheetMax = Math.floor(sys.windowHeight * 0.82)
+			const topReserved = uni.upx2px(320) + insetBottom
+			this.listHeight = Math.max(240, sheetMax - topReserved)
+		},
+		close() {
+			this.visible = false
+		},
+		pick(entry) {
+			this.$emit('input', entry.dial)
+			this.$emit('change', entry)
+			this.close()
+		},
+		isPicked(entry) {
+			return entry && this.current && entry.slug === this.current.slug
+		},
+		rowName(entry) {
+			return localizeRegion(entry, uni.getLocale())
+		}
+	}
+}
+</script>
+
+<style lang="scss">
+.region-trigger {
+	display: flex;
+	align-items: center;
+	padding: 0 18rpx;
+	height: 96rpx;
+	border-radius: 24rpx 0 0 24rpx;
+	background: rgba(255, 255, 255, 0.08);
+	border: 1rpx solid rgba(255, 255, 255, 0.16);
+	border-right: none;
+}
+
+.region-trigger--locked {
+	opacity: 0.4;
+}
+
+.region-trigger__flag {
+	font-size: 36rpx;
+	margin-right: 8rpx;
+}
+
+.region-trigger__code {
+	font-size: 30rpx;
+	color: #fff;
+	font-weight: 600;
+}
+
+.region-trigger__caret {
+	color: rgba(255, 255, 255, 0.7);
+	font-size: 20rpx;
+	margin-left: 6rpx;
+}
+
+.region-overlay {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	z-index: 1100;
+}
+
+.region-overlay__mask {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	background: rgba(2, 4, 24, 0.7);
+}
+
+.region-sheet {
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	width: 100%;
+	max-height: 82vh;
+	box-sizing: border-box;
+	display: flex;
+	flex-direction: column;
+	overflow: hidden;
+	background: linear-gradient(180deg, #1a1f3b 0%, #0c0f24 100%);
+	border-top-left-radius: 40rpx;
+	border-top-right-radius: 40rpx;
+	padding: 12rpx 24rpx 24rpx;
+	box-shadow: 0 -20rpx 60rpx rgba(0, 0, 0, 0.4);
+}
+
+.region-sheet__top {
+	flex-shrink: 0;
+}
+
+.region-sheet__handle {
+	width: 80rpx;
+	height: 6rpx;
+	border-radius: 6rpx;
+	margin: 12rpx auto 18rpx;
+	background: rgba(255, 255, 255, 0.25);
+}
+
+.region-sheet__head {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	padding: 0 8rpx 16rpx;
+}
+
+.region-sheet__title {
+	font-size: 32rpx;
+	color: #fff;
+	font-weight: 700;
+}
+
+.region-sheet__close {
+	color: rgba(255, 255, 255, 0.7);
+	font-size: 44rpx;
+	line-height: 1;
+	padding: 0 12rpx;
+}
+
+.region-sheet__search {
+	display: flex;
+	align-items: center;
+	box-sizing: border-box;
+	background: rgba(255, 255, 255, 0.08);
+	border: 1rpx solid rgba(255, 255, 255, 0.14);
+	border-radius: 28rpx;
+	padding: 16rpx 22rpx;
+	min-height: 88rpx;
+	margin-bottom: 20rpx;
+}
+
+.region-sheet__search-icon {
+	flex-shrink: 0;
+	color: rgba(255, 255, 255, 0.55);
+	font-size: 28rpx;
+	line-height: 1;
+	margin-right: 12rpx;
+}
+
+.region-sheet__search-input {
+	flex: 1;
+	min-width: 0;
+	width: 100%;
+	height: 56rpx;
+	line-height: 56rpx;
+	color: #fff;
+	font-size: 28rpx;
+	box-sizing: border-box;
+}
+
+.region-sheet__placeholder {
+	color: rgba(255, 255, 255, 0.4);
+}
+
+.region-sheet__search-clear {
+	flex-shrink: 0;
+	color: rgba(255, 255, 255, 0.55);
+	font-size: 36rpx;
+	line-height: 1;
+	padding: 0 8rpx 0 12rpx;
+}
+
+.region-sheet__list {
+	width: 100%;
+}
+
+.region-sheet__cluster {
+	margin-bottom: 18rpx;
+}
+
+.region-sheet__cluster-label {
+	display: block;
+	font-size: 22rpx;
+	color: rgba(255, 255, 255, 0.4);
+	letter-spacing: 4rpx;
+	padding: 12rpx 16rpx 8rpx;
+	text-transform: uppercase;
+}
+
+.region-row {
+	display: flex;
+	align-items: center;
+	padding: 22rpx 16rpx;
+	border-radius: 20rpx;
+}
+
+.region-row--on {
+	background: rgba(61, 90, 254, 0.22);
+}
+
+.region-row__flag {
+	font-size: 36rpx;
+	margin-right: 18rpx;
+}
+
+.region-row__name {
+	flex: 1;
+	font-size: 28rpx;
+	color: #fff;
+}
+
+.region-row__code {
+	font-size: 26rpx;
+	color: rgba(255, 255, 255, 0.7);
+	margin-right: 18rpx;
+}
+
+.region-row__on {
+	color: #ffb547;
+	font-size: 28rpx;
+	font-weight: 700;
+}
+
+.region-sheet__empty {
+	padding: 80rpx 40rpx;
+	text-align: center;
+}
+
+.region-sheet__empty-text {
+	color: rgba(255, 255, 255, 0.6);
+	font-size: 26rpx;
+}
+</style>

+ 432 - 0
components/profile/relation-list.vue

@@ -0,0 +1,432 @@
+<template>
+	<view class="rel-page">
+		<view class="rel-nav" :style="{ paddingTop: statusBar + 'px' }">
+			<view class="rel-nav-row">
+				<view class="rel-back" @tap="goBack">
+					<text class="rel-back-icon">←</text>
+				</view>
+				<text class="rel-nav-title">{{ title }}</text>
+				<view class="rel-nav-placeholder" />
+			</view>
+		</view>
+
+		<scroll-view
+			scroll-y
+			class="rel-scroll"
+			:style="{ height: scrollHeight + 'px' }"
+			:lower-threshold="120"
+			@scrolltolower="onScrollToLower"
+		>
+			<view class="rel-body" :style="{ paddingBottom: safeBottom + 'px' }">
+				<view v-if="loading && !rows.length" class="rel-state">
+					<text class="rel-state-txt">{{ labels.loading }}</text>
+				</view>
+
+				<empty
+					v-else-if="loaded && !rows.length"
+					src="find_default3x"
+					:text="labels.empty"
+				/>
+
+				<view v-else class="rel-list">
+					<view
+						v-for="(item, index) in rows"
+						:key="rowKey(item, index)"
+						class="rel-card"
+						@tap="openUser(item)"
+					>
+						<image
+							class="rel-avatar"
+							:src="avatarUrl(item)"
+							mode="aspectFill"
+						/>
+						<view class="rel-main">
+							<view class="rel-info">
+								<view class="rel-name-row">
+									<text v-if="item.shop" class="rel-tag">{{ labels.shopTag }}</text>
+									<text class="rel-name">{{ displayName(item) }}</text>
+								</view>
+								<text class="rel-bio">{{ displayBio(item) }}</text>
+							</view>
+							<button
+								class="rel-follow-btn"
+								:class="{ 'rel-follow-btn--on': item.isFollow === 1 }"
+								:disabled="followBusyIndex === index"
+								@tap.stop="toggleFollow(index)"
+							>
+								{{ item.isFollow === 1 ? labels.followed : labels.follow }}
+							</button>
+						</view>
+					</view>
+				</view>
+
+				<uni-load-more
+					v-if="rows.length"
+					:status="loadStatus"
+					:content-text="loadMoreContent"
+				/>
+			</view>
+		</scroll-view>
+	</view>
+</template>
+
+<script>
+import { fetchRelationPage, toggleProfileFollow } from '@/features/profile/profile-gateway'
+import { stripRichText } from '@/features/profile/catalog-normalize'
+
+export default {
+	name: 'ProfileRelationList',
+	props: {
+		userNo: {
+			type: [String, Number],
+			default: ''
+		},
+		listType: {
+			type: String,
+			required: true
+		},
+		title: {
+			type: String,
+			default: ''
+		},
+		emptyText: {
+			type: String,
+			default: ''
+		},
+		followLabel: {
+			type: String,
+			default: ''
+		},
+		unfollowLabel: {
+			type: String,
+			default: ''
+		},
+		defaultBio: {
+			type: String,
+			default: ''
+		},
+		loadingText: { type: String, default: '' },
+		noMoreText: { type: String, default: '' },
+		loadMoreHint: { type: String, default: '' },
+		loadFailedText: { type: String, default: '' },
+		actionFailedText: { type: String, default: '' },
+		shopTag: { type: String, default: '' }
+	},
+	data() {
+		return {
+			statusBar: 0,
+			scrollHeight: 600,
+			safeBottom: 0,
+			rows: [],
+			page: 1,
+			lastPage: 1,
+			loading: false,
+			loaded: false,
+			loadStatus: 'more',
+			followBusyIndex: -1
+		}
+	},
+	computed: {
+		labels() {
+			const t = this.$t.bind(this)
+			return {
+				loading: this.loadingText || t('relation.loading'),
+				noMore: this.noMoreText || t('relation.noMore'),
+				loadMore: this.loadMoreHint || t('relation.loadMore'),
+				empty: this.emptyText || t('relation.emptyData'),
+				follow: this.followLabel || t('mine.follow'),
+				followed: this.unfollowLabel || t('mine.followed'),
+				defaultBio: this.defaultBio || t('findUser.defaultBio'),
+				shopTag: this.shopTag || t('relation.shopTag'),
+				loadFailed: this.loadFailedText || t('relation.loadFailed'),
+				actionFailed: this.actionFailedText || t('relation.actionFailed')
+			}
+		},
+		loadMoreContent() {
+			return {
+				contentdown: this.labels.loadMore,
+				contentrefresh: this.labels.loading,
+				contentnomore: this.labels.noMore
+			}
+		}
+	},
+	watch: {
+		userNo: {
+			immediate: true,
+			handler(val) {
+				if (val) this.reload()
+			}
+		}
+	},
+	methods: {
+		initLayout() {
+			try {
+				const info = uni.getSystemInfoSync()
+				this.statusBar = info.statusBarHeight || 0
+				this.safeBottom = (info.safeAreaInsets && info.safeAreaInsets.bottom) || 0
+				const navH = this.statusBar + uni.upx2px(88)
+				this.scrollHeight = (info.windowHeight || info.screenHeight || 600) - navH
+			} catch (_) {}
+		},
+		rowKey(item, index) {
+			return item.id || item.user_no || `row-${index}`
+		},
+		avatarUrl(item) {
+			const path = item.shop
+				? item.shop.avatar
+				: (item.user && item.user.avatar) || ''
+			return path ? this.$appKit.oss(path, 88, 88, 2, 'avatar') : ''
+		},
+		displayName(item) {
+			if (item.shop && item.shop.shopname) return item.shop.shopname
+			return (item.user && item.user.nickname) || '—'
+		},
+		displayBio(item) {
+			const raw = item.shop
+				? item.shop.bio
+				: (item.user && item.user.bio) || ''
+			const text = stripRichText(raw)
+			return text || this.labels.defaultBio
+		},
+		goBack() {
+			uni.navigateBack({
+				fail: () => uni.switchTab({ url: '/pages/mine/index' })
+			})
+		},
+		openUser(item) {
+			if (!item || !item.user_no) return
+			uni.navigateTo({
+				url: `/pages/profile/show?id=${encodeURIComponent(String(item.user_no))}`
+			})
+		},
+		async reload() {
+			if (!this.userNo) return
+			this.page = 1
+			this.lastPage = 1
+			this.loadStatus = 'more'
+			this.loaded = false
+			this.rows = []
+			await this.loadPage(true)
+		},
+		onScrollToLower() {
+			if (this.loadStatus === 'loading' || this.loadStatus === 'noMore') return
+			this.loadPage(false)
+		},
+		async loadPage(reset) {
+			if (!this.userNo || this.loading) return
+			if (!reset && this.page >= this.lastPage) {
+				this.loadStatus = 'noMore'
+				return
+			}
+			const page = reset ? 1 : this.page + 1
+			this.loading = true
+			this.loadStatus = 'loading'
+			try {
+				const pack = await fetchRelationPage({
+					userNo: this.userNo,
+					listType: this.listType,
+					page
+				})
+				this.page = pack.currentPage
+				this.lastPage = pack.lastPage
+				if (reset) {
+					this.rows = pack.rows
+				} else {
+					this.rows = this.rows.concat(pack.rows)
+				}
+				this.loadStatus =
+					pack.currentPage >= pack.lastPage || !pack.rows.length ? 'noMore' : 'more'
+				this.loaded = true
+			} catch (err) {
+				this.loadStatus = 'more'
+				if (reset) this.rows = []
+				this.$appKit && this.$appKit.msg(err.message || this.labels.loadFailed)
+				this.loaded = true
+			} finally {
+				this.loading = false
+			}
+		},
+		async toggleFollow(index) {
+			if (!this.$appKit || !this.$appKit.requireLogin()) return
+			const row = this.rows[index]
+			if (!row || !row.user_no) return
+			if (this.followBusyIndex === index) return
+			this.followBusyIndex = index
+			try {
+				const flag = await toggleProfileFollow(row.user_no)
+				if (flag === 0 || flag === '0') row.isFollow = 0
+				else if (flag === 1 || flag === '1') row.isFollow = 1
+			} catch (err) {
+				this.$appKit.msg(err.message || this.labels.actionFailed)
+			} finally {
+				this.followBusyIndex = -1
+			}
+		}
+	},
+	mounted() {
+		this.initLayout()
+	}
+}
+</script>
+
+<style scoped>
+.rel-page {
+	min-height: 100vh;
+	background-color: #f4f6fb;
+}
+
+.rel-nav {
+	background-color: #ffffff;
+	border-bottom: 1rpx solid #eef1f6;
+}
+
+.rel-nav-row {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	height: 88rpx;
+	padding: 0 24rpx;
+	box-sizing: border-box;
+}
+
+.rel-back {
+	width: 64rpx;
+	height: 64rpx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.rel-back-icon {
+	font-size: 40rpx;
+	color: #1a1d26;
+	line-height: 1;
+}
+
+.rel-nav-title {
+	flex: 1;
+	text-align: center;
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #1a1d26;
+}
+
+.rel-nav-placeholder {
+	width: 64rpx;
+}
+
+.rel-body {
+	padding: 24rpx 28rpx 0;
+	box-sizing: border-box;
+}
+
+.rel-state {
+	padding: 120rpx 0;
+	text-align: center;
+}
+
+.rel-state-txt {
+	font-size: 28rpx;
+	color: #8a8d9e;
+}
+
+.rel-list {
+	display: flex;
+	flex-direction: column;
+}
+
+.rel-card {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	padding: 24rpx;
+	margin-bottom: 20rpx;
+	background-color: #ffffff;
+	border-radius: 24rpx;
+	box-shadow: 0 6rpx 24rpx rgba(74, 140, 255, 0.06);
+	box-sizing: border-box;
+}
+
+.rel-avatar {
+	width: 100rpx;
+	height: 100rpx;
+	border-radius: 50rpx;
+	flex-shrink: 0;
+	background-color: #e8edf5;
+}
+
+.rel-main {
+	flex: 1;
+	min-width: 0;
+	margin-left: 20rpx;
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+}
+
+.rel-info {
+	flex: 1;
+	min-width: 0;
+	margin-right: 16rpx;
+}
+
+.rel-name-row {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	margin-bottom: 8rpx;
+}
+
+.rel-tag {
+	flex-shrink: 0;
+	margin-right: 8rpx;
+	padding: 2rpx 10rpx;
+	border-radius: 8rpx;
+	background: linear-gradient(135deg, #4a8cff 0%, #6ec8ff 100%);
+	font-size: 20rpx;
+	color: #ffffff;
+}
+
+.rel-name {
+	font-size: 30rpx;
+	font-weight: 600;
+	color: #1a1d26;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.rel-bio {
+	display: block;
+	font-size: 24rpx;
+	color: #8a8d9e;
+	line-height: 1.4;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.rel-follow-btn {
+	flex-shrink: 0;
+	margin: 0;
+	padding: 0 28rpx;
+	height: 56rpx;
+	line-height: 56rpx;
+	font-size: 24rpx;
+	color: #ffffff;
+	background: linear-gradient(135deg, #4a8cff 0%, #6ec8ff 100%);
+	border-radius: 999rpx;
+	border: none;
+}
+
+.rel-follow-btn--on {
+	color: #8a8d9e;
+	background: #f4f6fb;
+	border: 1rpx solid #e8edf5;
+}
+
+.rel-follow-btn::after {
+	border: none;
+}
+</style>

+ 255 - 0
components/shop/shop-classify.vue

@@ -0,0 +1,255 @@
+<template>
+  <view class="wanlshop-classify-container">
+    <block v-if="categoryStyle === 1">
+      <scroll-view :style="{ height: mainHeight + 'px' }" scroll-y class="wanlshop-classify-container__one">
+        <view class="item" v-for="(item, index) in categoryData" :key="item.id" @tap="handleProduct(item.id, item.name)">
+          <image :src="item.image" class="list-image" mode="aspectFill" />
+          <view class="category-title">
+            <view>{{ item.description }}</view>
+            <text class="text-sm text-bold text-black">- {{ item.name }} -</text>
+          </view>
+        </view>
+      </scroll-view>
+    </block>
+    <block v-else-if="categoryStyle === 2">
+      <scroll-view class="wanlshop-classify-container__grid" :style="{ height: mainHeight + 'px' }" scroll-y>
+        <view class="cu-list grid col-3 no-border">
+          <view class="cu-item" v-for="(item, index) in categoryData" :key="item.id" @tap="handleProduct(item.id, item.name)">
+            <image :src="item.image" class="list-image" />
+            <text>{{ item.name }}</text>
+          </view>
+          <view class="safeAreaBottom"></view>
+        </view>
+      </scroll-view>
+    </block>
+    <block v-else-if="categoryStyle === 3">
+      <view class="wanlshop-classify-container__list">
+        <scroll-view scroll-y scroll-with-animation class="left" :scroll-top="scrollTop" :style="{ height: mainHeight + 'px' }">
+          <view v-for="(item, index) in categoryData" :key="item.id" class="item"
+            :class="[currentTab == index ? 'active' : '']" :data-current="index" @tap.stop="handleNav">
+            <text>{{ item.name }}</text>
+          </view>
+        </scroll-view>
+        <block v-for="(item, index) in categoryData" :key="item.id">
+          <scroll-view scroll-y class="right" :style="{ height: mainHeight + 'px' }" v-if="currentTab == index">
+            <view class="list-cat">
+              <view class="padding-top-sm">
+                <view class="list-item radius-bock">
+                  <view class="list-container">
+                    <view class="list-box" v-for="(category, infokey) in item.childlist"
+                      :key="category.id" @tap="handleProduct(category.id, category.name)">
+                      <image :src="category.image" class="list-image" />
+                      <view class="text-sm">{{ category.name }}</view>
+                    </view>
+                  </view>
+                </view>
+              </view>
+            </view>
+          </scroll-view>
+        </block>
+      </view>
+    </block>
+    <block v-else-if="categoryStyle === 4">
+      <view class="wanlshop-classify-container__list">
+        <scroll-view scroll-y scroll-with-animation class="left" :scroll-top="scrollTop"
+          :style="{ height: mainHeight + 'px' }">
+          <view v-for="(item, index) in categoryData" :key="item.id" class="item"
+            :class="[currentTab == index ? 'active' : '']" :data-current="index" @tap.stop="handleNav">
+            <text>{{ item.name }}</text>
+          </view>
+          <view class="edgeInsetBottom"></view>
+        </scroll-view>
+        <block v-for="(item, index) in categoryData" :key="item.id">
+          <scroll-view scroll-y class="right" :style="{ height: mainHeight + 'px' }" v-if="currentTab == index">
+            <view class="list-cat">
+              <view class="padding-top-sm">
+                <view class="list-item radius-bock" v-for="(childlist, childkey) in item.childlist"
+                  :key="childlist.id">
+                  <view class="flex justify-between text-sm"
+                    @tap="handleProduct(childlist.id, childlist.name)">
+                    <text>{{ childlist.name }}</text>
+                    <text class="wlIcon wlIcon-fanhui2 wanl-gray-light"></text>
+                  </view>
+                  <view class="list-container">
+                    <view class="list-box" v-for="(category, infokey) in childlist.childlist"
+                      :key="category.id" @tap="handleProduct(category.id, category.name)">
+                      <image :src="category.image" class="list-image" />
+                      <view class="text-sm">{{ category.name }}</view>
+                    </view>
+                  </view>
+                </view>
+                <view class="edgeInsetBottom"></view>
+              </view>
+            </view>
+          </scroll-view>
+        </block>
+      </view>
+    </block>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    shopId: { type: Number, default: 0 },
+    categoryStyle: { type: Number, default: 1 },
+    mainHeight: { type: Number, default: 0 },
+    categoryData: { type: Array, default: () => [] }
+  },
+  data() {
+    return {
+      currentTab: 0,
+      scrollTop: 0
+    }
+  },
+  methods: {
+    handleNav(e) {
+      let cur = e.currentTarget.dataset.current
+      if (this.currentTab == cur) return false
+      this.currentTab = cur
+      this.checkCor()
+    },
+    handleProduct(category_id, category_name) {
+      if (this.shopId === 0) {
+        const dataStr = encodeURIComponent(JSON.stringify({ category_id, category_name }))
+        uni.navigateTo({ url: `/pages/product/list?data=${dataStr}` })
+      } else {
+        uni.navigateTo({
+          url: `/pages/product/list?shop_id=${this.shopId}&category_id=${category_id}&category_name=${encodeURIComponent(category_name)}`
+        })
+      }
+    },
+    checkCor() {
+      if (this.currentTab > 7) {
+        this.scrollTop = 500
+      } else {
+        this.scrollTop = 0
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.wanlshop-classify-container {
+  &__one {
+    padding-left: 25rpx;
+    padding-right: 25rpx;
+    padding-bottom: 100rpx;
+    padding-bottom: calc(env(safe-area-inset-bottom) + 100rpx);
+    image {
+      width: 100%;
+      height: 300rpx;
+      border-radius: 15rpx;
+      overflow: hidden;
+    }
+    .item {
+      position: relative;
+      display: flex;
+      justify-content: center;
+      margin-top: 25rpx;
+      .category-title {
+        font-size: 26rpx;
+        position: absolute;
+        bottom: 20rpx;
+        border-radius: 10rpx;
+        overflow: hidden;
+        color: #666666;
+        background-color: rgba(255, 255, 255, 0.6);
+        width: 60%;
+        text-align: center;
+        padding: 4rpx 0;
+      }
+    }
+  }
+  &__grid {
+    padding-bottom: 100rpx;
+    padding-bottom: calc(env(safe-area-inset-bottom) + 100rpx);
+    .cu-list {
+      background-color: rgba(255, 255, 255, 0);
+      & > .cu-item {
+        padding: 25rpx 0;
+        align-items: center;
+        image {
+          width: 160rpx;
+          height: 160rpx;
+          margin-bottom: 10rpx;
+        }
+      }
+    }
+  }
+  &__list {
+    display: flex;
+    .left {
+      width: 200rpx;
+      z-index: 10;
+      background: #fbfbfb;
+      .item {
+        width: 200rpx;
+        height: 110rpx;
+        box-sizing: border-box;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-size: 26rpx;
+        color: #444;
+        font-weight: 400;
+        &.active {
+          position: relative;
+          color: #000;
+          font-size: 30rpx;
+          font-weight: 600;
+          background: white;
+          &::before {
+            content: '';
+            position: absolute;
+            border-left: 6rpx solid #fe6600;
+            height: 32rpx;
+            left: 0;
+          }
+        }
+      }
+    }
+    .right {
+      width: 100%;
+      padding-left: 25rpx;
+      box-sizing: border-box;
+      .list-cat {
+        width: 100%;
+        overflow: hidden;
+        padding-top: 10rpx;
+        padding-right: 20rpx;
+        box-sizing: border-box;
+      }
+      .list-item {
+        background: #ffffff;
+        width: 100%;
+        box-sizing: border-box;
+        padding: 20rpx;
+        margin-bottom: 20rpx;
+      }
+      .list-container {
+        display: flex;
+        justify-content: flex-start;
+        flex-direction: row;
+        flex-wrap: wrap;
+      }
+      .list-box {
+        width: 33.3333%;
+        text-align: center;
+        padding-top: 40rpx;
+      }
+      .list-image {
+        width: 120rpx;
+        height: 120rpx;
+      }
+    }
+  }
+}
+.safeAreaBottom {
+  padding-bottom: env(safe-area-inset-bottom);
+}
+.edgeInsetBottom {
+  height: 100rpx;
+}
+</style>

+ 116 - 0
components/shop/shop-find.vue

@@ -0,0 +1,116 @@
+<template>
+  <scroll-view class="shop-find-container" :style="{ height: windowHeight + 'px' }" scroll-y enable-back-to-top @scrolltolower="handleLower">
+    <view v-if="dataList.length === 0 && status !== 'loading'" class="empty-find">
+      <text class="wanl-gray">暫無動態</text>
+    </view>
+    <view v-if="dataList.length > 0" class="find-list">
+      <view class="item margin-bj" v-for="item in dataList" :key="item.id">
+        <view class="time">
+          <text class="day text-xxl text-bold margin-right-xs">{{ item.createtime_date || '' }}</text>
+        </view>
+        <view class="main">
+          <view class="title text-cut-2">
+            <view class="cu-tag sm radius margin-right-xs" :class="[typeList[item.type] || 'wanl-bg-blue']">{{ item.type_text || '' }}</view>
+            {{ item.content || '' }}
+          </view>
+          <view v-if="item.images && item.images.length > 0" class="container margin-top-sm">
+            <view v-for="(img, kes) in item.images" :key="kes" class="list radius-bock"
+              :style="{ backgroundImage: 'url(' + img + ')' }" @tap="previewImage(item.images, kes)" />
+          </view>
+        </view>
+      </view>
+    </view>
+    <view class="load-more" v-if="dataList.length > 0">
+      <text v-if="status === 'loading'">加載中...</text>
+      <text v-else-if="status === 'noMore'">沒有更多了</text>
+    </view>
+  </scroll-view>
+</template>
+
+<script>
+import { pullMallShopFindList } from '@/features/mall/mall-gateway'
+
+export default {
+  props: {
+    windowHeight: { type: Number, default: 0 },
+    tabbarHeight: { type: Number, default: 0 },
+    userNo: { type: String, default: '' }
+  },
+  data() {
+    return {
+      dataList: [],
+      current_page: 1,
+      last_page: 1,
+      status: 'loading',
+      typeList: { live: 'wanl-bg-pink', video: 'wanl-bg-purple', goods: 'wanl-bg-orange', article: 'wanl-bg-blue' }
+    }
+  },
+  mounted() {
+    this.loadData()
+  },
+  methods: {
+    async loadData() {
+      try {
+        this.status = 'loading'
+        const res = await pullMallShopFindList({ user_no: this.userNo, page: this.current_page })
+        this.dataList = this.current_page === 1 ? res.list : this.dataList.concat(res.list)
+        this.last_page = res.lastPage
+        this.status = this.current_page >= this.last_page ? 'noMore' : 'more'
+      } catch (e) {
+        this.status = 'noMore'
+      }
+    },
+    handleLower() {
+      if (this.current_page < this.last_page) {
+        this.current_page += 1
+        this.loadData()
+      }
+    },
+    previewImage(images, index) {
+      uni.previewImage({ urls: images, current: images[index], indicator: 'number' })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.shop-find-container {
+  background-color: #f9f9f9;
+}
+.empty-find {
+  display: flex;
+  justify-content: center;
+  padding: 200rpx 0;
+  font-size: 28rpx;
+}
+.find-list {
+  .item {
+    background: #fff;
+    border-radius: 16rpx;
+    padding: 20rpx;
+    margin-bottom: 20rpx;
+    .time { margin-bottom: 15rpx; }
+    .main {
+      .title { font-size: 28rpx; line-height: 1.5; }
+      .container {
+        display: flex;
+        flex-wrap: wrap;
+        .list {
+          width: 200rpx;
+          height: 200rpx;
+          margin-right: 10rpx;
+          margin-bottom: 10rpx;
+          background-size: cover;
+          background-position: center;
+        }
+      }
+    }
+  }
+}
+.load-more {
+  text-align: center;
+  padding: 30rpx 0;
+  color: #999;
+  font-size: 24rpx;
+}
+</style>

+ 285 - 0
components/shop/shop-head.vue

@@ -0,0 +1,285 @@
+<template>
+  <view class="shop-head-container" :style="{ height: headHeight + 'px', paddingTop: headTop + 'px' }">
+    <!-- 返回按鈕 -->
+    <view v-if="pagesNum > 1" class="text-xl text-bold" @tap="handleBack">
+      <text class="wlIcon wlIcon-fanhui1"></text>
+    </view>
+    <!-- 首頁按鈕 -->
+    <view v-else class="text-xl text-bold" @tap="handleHome">
+      <text class="wlIcon wlIcon-zhuye"></text>
+    </view>
+    <view class="content">
+      <!-- 首頁導航 -->
+      <view v-if="tabbarId === 'home'" class="home">
+        <view :class="{ active: headTabbarId === 'page' }" @tap="$emit('headId', 'page')">
+          <text>推薦</text>
+        </view>
+        <view :class="{ active: headTabbarId === 'news' }" @tap="$emit('headId', 'news')">
+          <text>新品</text>
+        </view>
+      </view>
+      <!-- 搜索框 -->
+      <view v-else class="search round" @tap="handleSearch">
+        <text class="wlIcon wlIcon-sousuo1 text-bold margin-left-bj margin-right-xs"></text>
+        <text>搜索店內商品</text>
+      </view>
+    </view>
+    <!-- 更多操作按鈕 -->
+    <view class="text-xl">
+      <text v-if="tabbarId === 'home'" class="wlIcon wlIcon-sousuo1 margin-right" @tap="handleSearch"></text>
+      <text class="wlIcon wlIcon-gengduo" @tap="showModal('menu')"></text>
+    </view>
+    <!-- 功能直連弹窗 -->
+    <view class="WANL-MODAL">
+      <view class="cu-modal top-modal" :class="{ show: modalName === 'menu' }" @tap="hideModal">
+        <view class="wanl-modal-menu cu-dialog" @tap.stop="">
+          <view class="wanl-direct">
+            <!-- 功能直連標題 -->
+            <view class="menu-header" :style="{ paddingTop: headTop + 'px' }">
+              <view>功能直連</view>
+              <view @tap="hideModal">
+                <text class="wlIcon-31guanbi"></text>
+              </view>
+            </view>
+            <!-- 功能直連列表 -->
+            <view class="menu-box">
+              <!-- 消息 -->
+              <view class="menu-item" hover-class="wanl-opcity" @tap="handleNav('/pages/notice/mall-notice/index')">
+                <view class="badge-box">
+                  <text class="wlIcon-xiaoxizhongxin"></text>
+                  <view class="cu-tag badge"
+                    v-if="statistics.notice.notice + statistics.notice.order + statistics.notice.chat > 0">{{
+                      statistics.notice.notice + statistics.notice.order + statistics.notice.chat }}</view>
+                </view>
+                <view class="menu-text">消息</view>
+              </view>
+              <!-- 首頁 -->
+              <view class="menu-item" hover-class="wanl-opcity" @tap="handleNav('/pages/mall/index')">
+                <view class="badge-box">
+                  <text class="wlIcon-31shouye"></text>
+                </view>
+                <view class="menu-text">首頁</view>
+              </view>
+              <!-- 我的 -->
+              <view class="menu-item" hover-class="wanl-opcity" @tap="handleSwitchTab('/pages/mine/index')">
+                <view class="badge-box">
+                  <text class="wlIcon-31wode"></text>
+                  <view class="cu-tag badge"
+                    v-if="statistics.order.pay + statistics.order.delive + statistics.order.receiving + statistics.order.evaluate > 0">
+                    {{ statistics.order.pay + statistics.order.delive + statistics.order.receiving +
+                      statistics.order.evaluate }}</view>
+                </view>
+                <view class="menu-text">我的</view>
+              </view>
+              <!-- 購物車 -->
+              <view class="menu-item" hover-class="wanl-opcity" @tap="handleCart">
+                <view class="badge-box">
+                  <text class="wlIcon-gouwuche"></text>
+                  <view class="cu-tag badge" v-if="cart.cartnum > 0">{{ cart.cartnum }}</view>
+                </view>
+                <view class="menu-text">購物車</view>
+              </view>
+              <!-- 服務 -->
+              <view class="menu-item" hover-class="wanl-opcity" @tap="handleNav('/pages/user/service')">
+                <view class="badge-box">
+                  <text class="wlIcon-unie737"></text>
+                </view>
+                <view class="menu-text">服務</view>
+              </view>
+              <!-- 反饋 -->
+              <view class="menu-item" hover-class="wanl-opcity" @tap="handleNav('/pages/account/feedback')">
+                <view class="badge-box">
+                  <text class="wlIcon-xiugaioryijian"></text>
+                </view>
+                <view class="menu-text">反饋</view>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+export default {
+  props: {
+    headHeight: { type: Number, default: 0 },
+    headTop: { type: Number, default: 0 },
+    shopId: { type: Number, default: 0 },
+    shopDescription: { type: String, default: '' },
+    pagesNum: { type: Number, default: 0 },
+    tabbarId: { type: String, default: '' },
+    headTabbarId: { type: String, default: 'page' }
+  },
+  data() {
+    return { modalName: null }
+  },
+  computed: {
+    ...mapState({
+      statistics: state => state.statistics,
+      cart: state => state.cart
+    })
+  },
+  methods: {
+    // 顯示彈窗
+    showModal(name) {
+      this.modalName = name
+    },
+    // 隱藏彈窗
+    hideModal() {
+      this.modalName = null
+    },
+    // 返回上頁
+    handleBack() {
+      uni.navigateBack({ delta: 1 })
+    },
+    // 首頁
+    handleHome() {
+      uni.switchTab({ url: '/pages/mall/index' })
+    },
+    handleSearch() {
+      uni.navigateTo({ url: '/pages/page/search' })
+    },
+    // 导航到指定頁面
+    handleNav(url) {
+      // 隱藏彈窗
+      this.hideModal()
+      uni.navigateTo({ url })
+    },
+    // 切換頁面
+    handleSwitchTab(url) {
+      this.hideModal()
+      uni.switchTab({ url })
+    },
+    handleCart() {
+      this.hideModal()
+      uni.navigateTo({ url: '/pages/cart/index' })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.shop-head-container {
+  position: relative;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-left: 22rpx;
+  padding-right: 24rpx;
+
+  .content {
+    flex: 1;
+
+    .home {
+      display: flex;
+      align-items: center;
+      margin: 0 25rpx;
+      font-size: 32rpx;
+      color: #555;
+
+      &>view+view {
+        margin-left: 40rpx;
+      }
+
+      .active {
+        color: #333;
+        font-size: 38rpx;
+        font-weight: 600;
+      }
+    }
+
+    .search {
+      display: flex;
+      align-items: center;
+      margin: 0 25rpx;
+      background-color: #f9f9f9;
+      line-height: 68rpx;
+      height: 68rpx;
+      color: grey;
+    }
+  }
+}
+
+.WANL-MODAL {
+  .cu-modal {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 9999;
+    opacity: 0;
+    visibility: hidden;
+    transition: all 0.3s ease-in-out;
+
+    &.show {
+      opacity: 1;
+      visibility: visible;
+    }
+  }
+
+  .top-modal .cu-dialog {
+    width: 100%;
+    border-radius: 0 0 25rpx 25rpx;
+    background: rgba(60, 60, 60, 0.9);
+  }
+}
+
+.wanl-direct {
+  .menu-header {
+    font-size: 34rpx;
+    color: #fff;
+    display: flex;
+    justify-content: space-between;
+    padding: 20rpx 30rpx;
+  }
+
+  .menu-box {
+    color: #fff;
+    padding: 40rpx 1rpx 0 1rpx;
+    box-sizing: border-box;
+    display: flex;
+    flex-wrap: wrap;
+    font-size: 26rpx;
+
+    .menu-item {
+      width: 22%;
+      height: 160rpx;
+      border-radius: 24rpx;
+      display: flex;
+      align-items: center;
+      flex-direction: column;
+      justify-content: center;
+      background: rgba(0, 0, 0, 0.4);
+      margin-right: 4%;
+      margin-bottom: 4%;
+
+      &:nth-of-type(4n) {
+        margin-right: 0;
+      }
+
+      .badge-box {
+        position: relative;
+        font-size: 52rpx;
+
+        .cu-tag {
+          right: -25rpx;
+        }
+      }
+
+      .menu-text {
+        padding-top: 12rpx;
+      }
+    }
+
+    .wanl-opcity .menu-text,
+    .wanl-opcity .badge-box {
+      opacity: 0.5;
+      transition: opacity 0.2s ease-in-out;
+    }
+  }
+}
+</style>

+ 113 - 0
components/shop/shop-page.vue

@@ -0,0 +1,113 @@
+<template>
+  <scroll-view class="shop-page-container" :style="{ height: windowHeight + 'px' }" scroll-y enable-back-to-top>
+    <view v-if="shopData" class="shop-info padding-sm radius-bock" @tap="toShopInfo">
+      <view class="info">
+        <view class="name text-cut text-bold">
+          <block v-if="shopData.isself === 1">
+            <view class="cu-tag radius sm bg-red">自營</view>
+          </block>
+          <block v-else>
+            <view class="cu-tag radius sm wanl-bg-pink" v-if="shopData.state == 0">個人</view>
+            <view class="cu-tag radius sm wanl-bg-blue" v-else-if="shopData.state == 1">企業</view>
+            <view class="cu-tag radius sm wanl-bg-orange" v-else-if="shopData.state == 2">旗艦</view>
+          </block>
+          <text class="margin-left-xs"> {{ shopData.shopname }} </text>
+        </view>
+        <view class="exponent text-min wanl-gray-dark">
+          <view>
+            <text class="margin-right-xs">店鋪粉絲</text> {{ shopData.find_user && shopData.find_user.fans || 0 }}
+          </view>
+        </view>
+      </view>
+      <view class="menu">
+        <button v-if="shopData.isFollow === 0" class="cu-btn radius-bock text-sm bg-white" @click.stop="$emit('handleFollow')">
+          <text class="wlIcon wlIcon-31xuanze text-bold margin-right-xs"></text> 關注
+        </button>
+        <button v-else class="cu-btn radius-bock text-sm bg-white" @click.stop="$emit('handleFollow')">
+          <text class="wlIcon wlIcon-31guanbi text-bold margin-right-xs"></text> 取消關注
+        </button>
+      </view>
+    </view>
+    <view v-if="pageModules && pageModules.item && pageModules.item.length > 0" class="page-content">
+      <view v-for="(item, index) in pageModules.item" :key="index">
+        <view v-if="item.type === 'banner'" class="padding-sm">
+          <swiper class="screen-swiper" indicator-dots circular autoplay interval="4000" duration="500" v-if="item.data && item.data.length">
+            <swiper-item v-for="(banner, bindex) in item.data" :key="bindex">
+              <image :src="banner.image" mode="aspectFill" class="radius-bock" style="width:100%;height:300rpx;" />
+            </swiper-item>
+          </swiper>
+        </view>
+        <view v-if="item.type === 'image'" class="padding-sm">
+          <image v-for="(img, iindex) in item.data" :key="iindex" :src="img.image" mode="widthFix" style="width:100%;" />
+        </view>
+        <view v-if="item.type === 'goods'" class="padding-sm">
+          <view class="text-bold margin-bottom-sm">{{ item.title || '推薦商品' }}</view>
+        </view>
+        <view v-if="item.type === 'division'" class="padding-sm">
+          <view class="solid-bottom"></view>
+        </view>
+        <view v-if="item.type === 'empty'" :style="{ height: item.height + 'rpx' }"></view>
+      </view>
+    </view>
+    <view v-else class="empty-page">
+      <text class="wanl-gray">店鋪正在裝修中...</text>
+    </view>
+  </scroll-view>
+</template>
+
+<script>
+export default {
+  props: {
+    windowHeight: { type: Number, default: 0 },
+    shopData: { type: Object, default: () => ({}) },
+    pageModules: { type: Object, default: () => null }
+  },
+  methods: {
+    toShopInfo() {
+      if (this.shopData && this.shopData.id) {
+        uni.navigateTo({ url: '/pages/shop/info?id=' + this.shopData.id })
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.shop-page-container {
+  background-color: #f9f9f9;
+}
+.shop-info {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background-color: rgba(0, 0, 0, 0.05);
+  margin: 0 16rpx 16rpx 16rpx;
+  .info {
+    width: calc(100% - 260rpx);
+    display: flex;
+    align-content: space-between;
+    flex-wrap: wrap;
+    height: 85rpx;
+    .name {
+      width: 90%;
+    }
+    .exponent {
+      display: flex;
+    }
+  }
+  .menu {
+    text-align: right;
+    width: 260rpx;
+  }
+}
+.page-content {
+  background-color: #f9f9f9;
+}
+.empty-page {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 200rpx 0;
+  font-size: 28rpx;
+}
+</style>

+ 188 - 0
components/shop/shop-product.vue

@@ -0,0 +1,188 @@
+<template>
+  <view class="shop-product-container" :style="{ height: windowHeight + 'px' }">
+    <view class="shop-product-container__bar">
+      <view class="list">
+        <view class="item" :class="{ active: filterIndex === 0 }" @tap="handleBarTab(0)">綜合</view>
+        <view class="item" :class="{ active: filterIndex === 1 }" @tap="handleBarTab(1)">銷量</view>
+        <view class="item" :class="{ active: filterIndex === 2 }" @tap="handleBarTab(2)">新品</view>
+        <view class="item" :class="{ active: filterIndex === 3 }" @tap="handleBarTab(3)">
+          <view>價格</view>
+          <view class="box">
+            <text :class="{ active: priceOrder === 1 && filterIndex === 3 }" class="wlIcon wlIcon-sheng" />
+            <text :class="{ active: priceOrder === 2 && filterIndex === 3 }" class="wlIcon wlIcon-jiang" />
+          </view>
+        </view>
+      </view>
+      <view class="text-30 text-bold" @tap="handleListStyle">
+        <text class="wlIcon wlIcon-listing-jf" v-if="listStyle === 'col-2-10'"></text>
+        <text class="wlIcon wlIcon-liebiao" v-else></text>
+      </view>
+    </view>
+    <scroll-view :style="{ height: (windowHeight - 40) + 'px' }" scroll-y enable-back-to-top
+      @scrolltolower="handleLower">
+      <mall-product v-if="dataList.length > 0" :dataList="dataList" :dataStyle="listStyle" />
+      <view v-if="dataList.length === 0 && status !== 'loading'" class="empty-list">
+        <text class="wanl-gray">暫無商品</text>
+      </view>
+      <view class="load-more" v-if="dataList.length > 0">
+        <text v-if="status === 'loading'">加載中...</text>
+        <text v-else-if="status === 'noMore'">沒有更多了</text>
+      </view>
+      <view :style="{ height: tabbarHeight + 'px' }" />
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import { pullMallProductPage } from '@/features/mall/mall-gateway'
+import MallProduct from '@/components/mall/mall-product.vue'
+export default {
+  components: { MallProduct },
+  props: {
+    tabbarHeight: { type: Number, default: 0 },
+    windowHeight: { type: Number, default: 0 },
+    tabId: { type: String, default: '' },
+    option: { type: Object, default: () => ({}) }
+  },
+  data() {
+    return {
+      filterIndex: 0,
+      priceOrder: 0,
+      listStyle: 'col-1-10',
+      dataList: [],
+      params: { search: '', sort: 'weigh', order: 'asc', page: 1, filter: {}, op: {}, type: 'goods' },
+      last_page: 1,
+      status: 'loading'
+    }
+  },
+  mounted() {
+    if (this.tabId === 'goods') {
+      this.initialize(this.tabId)
+    } else {
+      this.initialize('page')
+    }
+  },
+  methods: {
+    initialize(type) {
+      this.params.type = type === 'groups' ? 'groups' : 'goods'
+      if (this.option.shop_id) {
+        this.params.filter.shop_id = this.option.shop_id
+        this.params.op.shop_id = 'in'
+        if (type === 'news') {
+          this.params.sort = 'createtime'
+          this.params.order = 'desc'
+        }
+      }
+      if (this.option.category_id) {
+        this.params.filter.shop_category_id = this.option.category_id
+        this.params.op.shop_category_id = '='
+      }
+      this.params.page = 1
+      this.loadData()
+    },
+    async loadData() {
+      try {
+        this.status = 'loading'
+        const res = await pullMallProductPage(this.params)
+        const list = res.rows || []
+        this.dataList = this.params.page === 1 ? list : this.dataList.concat(list)
+        this.last_page = res.lastPage || 1
+        this.status = this.params.page >= this.last_page ? 'noMore' : 'more'
+      } catch (e) {
+        this.status = 'noMore'
+      }
+    },
+    handleBarTab(index) {
+      this.filterIndex = index
+      if (index === 0) { this.params.sort = 'weigh'; this.params.order = 'asc' }
+      else if (index === 1) { this.params.sort = 'sales'; this.params.order = 'desc' }
+      else if (index === 2) { this.params.sort = 'createtime'; this.params.order = 'desc' }
+      else if (index === 3) {
+        this.priceOrder = this.priceOrder === 1 ? 2 : 1
+        this.params.sort = 'price'
+        this.params.order = this.priceOrder === 1 ? 'asc' : 'desc'
+      }
+      this.params.page = 1
+      this.loadData()
+    },
+    handleListStyle() {
+      this.listStyle = this.listStyle === 'col-1-10' ? 'col-2-10' : 'col-1-10'
+    },
+    handleLower() {
+      if (this.params.page < this.last_page) {
+        this.params.page += 1
+        this.loadData()
+      }
+    }
+  },
+  watch: {
+    tabId(newVal, oldVal) {
+      if (newVal === 'news' || newVal === 'groups') {
+        this.dataList = []
+        this.initialize(newVal)
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.shop-product-container {
+  background-color: #f9f9f9;
+  border-radius: 30rpx 30rpx 0 0;
+
+  &__bar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 0 30rpx;
+    height: 40px;
+    overflow: hidden;
+    color: #555;
+
+    .list {
+      width: 75%;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+
+      .item {
+        display: flex;
+        align-items: center;
+
+        &.active {
+          color: #f40;
+          font-weight: bold;
+        }
+
+        .box {
+          color: #ccc;
+          margin-top: 2rpx;
+          margin-left: 8rpx;
+          display: flex;
+          flex-direction: column;
+          line-height: 1;
+
+          .active {
+            color: #f40;
+          }
+        }
+      }
+    }
+  }
+}
+
+.empty-list {
+  display: flex;
+  justify-content: center;
+  padding: 200rpx 0;
+  font-size: 28rpx;
+}
+
+.load-more {
+  text-align: center;
+  padding: 30rpx 0;
+  color: #999;
+  font-size: 24rpx;
+}
+</style>

+ 465 - 0
components/travel/city-picker/city-picker.vue

@@ -0,0 +1,465 @@
+<template>
+	<view>
+		<view class="city-trigger" @tap="open">
+			<slot>
+				<view class="city-trigger__default">
+					<text class="city-trigger__icon">📍</text>
+					<text class="city-trigger__text">{{ displayCity || placeholder }}</text>
+					<text class="city-trigger__arrow">▾</text>
+				</view>
+			</slot>
+		</view>
+
+		<view v-if="visible" class="city-overlay">
+			<view class="city-overlay__mask" @tap="close" />
+			<view class="city-sheet" :style="{ paddingBottom: bottomGap + 'px' }" @tap.stop>
+				<view class="city-sheet__header">
+					<view class="city-sheet__title-bar">
+						<text class="city-sheet__title">{{ title }}</text>
+						<text class="city-sheet__close" @tap="close">×</text>
+					</view>
+					<view class="city-sheet__search">
+						<text class="city-sheet__search-icon">🔎</text>
+						<input
+							v-model="keyword"
+							class="city-sheet__search-input"
+							:placeholder="searchPlaceholder"
+							placeholder-class="city-sheet__placeholder"
+							confirm-type="search"
+							@confirm="onSearch"
+							@tap.stop
+						/>
+						<text v-if="keyword" class="city-sheet__search-clear" @tap.stop="clearSearch">×</text>
+					</view>
+				</view>
+
+				<scroll-view scroll-y class="city-sheet__list" :style="{ height: listHeight + 'px' }" :show-scrollbar="false">
+					<view v-if="loading" class="city-sheet__loading">
+						<text class="city-sheet__loading-text">加载中...</text>
+					</view>
+
+					<block v-else-if="keyword">
+						<view v-if="searchResults.length === 0" class="city-sheet__empty">
+							<text class="city-sheet__empty-text">未找到相关城市</text>
+						</view>
+						<view v-else class="city-list">
+							<view v-for="(city, index) in searchResults" :key="city.code || index" class="city-list__item" :class="{ 'city-list__item--active': isCitySelected(city) }" @tap="selectCity(city)">
+								<text class="city-list__name">{{ city.name }}</text>
+								<text v-if="city.code" class="city-list__code">{{ city.code }}</text>
+								<text v-if="isCitySelected(city)" class="city-list__check">✓</text>
+							</view>
+						</view>
+					</block>
+
+					<block v-else>
+						<view v-if="historyList.length > 0" class="city-section">
+							<view class="city-section__header">
+								<text class="city-section__title">历史搜索</text>
+								<text class="city-section__clear" @tap="clearHistory">清除</text>
+							</view>
+							<view class="city-tags">
+								<view v-for="(item, index) in historyList" :key="'h-' + index" class="city-tag" @tap="selectCityByName(item)">
+									<text class="city-tag__text">{{ item }}</text>
+								</view>
+							</view>
+						</view>
+
+						<view class="city-section">
+							<view class="city-section__header">
+								<text class="city-section__title">热门城市</text>
+							</view>
+							<view class="city-tags">
+								<view v-for="(city, index) in hotCities" :key="'hot-' + index" class="city-tag" @tap="selectCity(city)">
+									<text class="city-tag__text">{{ city.name }}</text>
+								</view>
+							</view>
+						</view>
+
+						<view class="city-list">
+							<view v-for="group in cityGroups" :key="group.letter" class="city-group">
+								<view class="city-group__letter">{{ group.letter }}</view>
+								<view v-for="(city, index) in group.cities" :key="city.code || index" class="city-list__item" :class="{ 'city-list__item--active': isCitySelected(city) }" @tap="selectCity(city)">
+									<text class="city-list__name">{{ city.name }}</text>
+									<text v-if="city.code" class="city-list__code">{{ city.code }}</text>
+									<text v-if="isCitySelected(city)" class="city-list__check">✓</text>
+								</view>
+							</view>
+						</view>
+					</block>
+				</scroll-view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'CityPicker',
+	props: {
+		value: { type: [String, Object], default: '' },
+		placeholder: { type: String, default: '选择城市' },
+		title: { type: String, default: '选择城市' },
+		searchPlaceholder: { type: String, default: '搜索城市名称或代码' },
+		hotCities: {
+			type: Array,
+			default: () => [
+				{ name: '北京', code: '010' },
+				{ name: '上海', code: '021' },
+				{ name: '广州', code: '020' },
+				{ name: '深圳', code: '0755' },
+				{ name: '成都', code: '028' },
+				{ name: '杭州', code: '0571' },
+				{ name: '重庆', code: '023' },
+				{ name: '西安', code: '029' }
+			]
+		},
+		cityList: { type: Array, default: () => [] }
+	},
+	data() {
+		return {
+			visible: false,
+			keyword: '',
+			bottomGap: 0,
+			listHeight: 400,
+			loading: false,
+			searchResults: [],
+			historyList: []
+		}
+	},
+	computed: {
+		displayCity() {
+			if (!this.value) return ''
+			return typeof this.value === 'string' ? this.value : this.value.name || ''
+		},
+		cityGroups() {
+			const groups = {}
+			const list = this.cityList.length > 0 ? this.cityList : this.defaultCityList
+			list.forEach(city => {
+				const firstChar = city.name.charAt(0).toUpperCase()
+				const letter = /[A-Z]/.test(firstChar) ? firstChar : '#'
+				if (!groups[letter]) {
+					groups[letter] = { letter, cities: [] }
+				}
+				groups[letter].cities.push(city)
+			})
+			return Object.keys(groups)
+				.sort()
+				.map(key => groups[key])
+		},
+		defaultCityList() {
+			return [
+				{ name: '北京', code: '010' },
+				{ name: '上海', code: '021' },
+				{ name: '广州', code: '020' },
+				{ name: '深圳', code: '0755' },
+				{ name: '成都', code: '028' },
+				{ name: '杭州', code: '0571' },
+				{ name: '重庆', code: '023' },
+				{ name: '西安', code: '029' },
+				{ name: '南京', code: '025' },
+				{ name: '武汉', code: '027' },
+				{ name: '天津', code: '022' },
+				{ name: '苏州', code: '0512' },
+				{ name: '长沙', code: '0731' },
+				{ name: '郑州', code: '0371' },
+				{ name: '青岛', code: '0532' },
+				{ name: '大连', code: '0411' },
+				{ name: '厦门', code: '0592' },
+				{ name: '昆明', code: '0871' },
+				{ name: '三亚', code: '0898' },
+				{ name: '丽江', code: '0888' }
+			]
+		}
+	},
+	created() {
+		this.loadHistory()
+	},
+	methods: {
+		open() {
+			this.keyword = ''
+			this.visible = true
+			this.searchResults = []
+			const sys = uni.getSystemInfoSync()
+			const insetBottom = (sys.safeAreaInsets && sys.safeAreaInsets.bottom) || 0
+			this.bottomGap = insetBottom
+			const sheetMax = Math.floor(sys.windowHeight * 0.85)
+			const topReserved = uni.upx2px(360) + insetBottom
+			this.listHeight = Math.max(280, sheetMax - topReserved)
+		},
+		close() {
+			this.visible = false
+			this.keyword = ''
+			this.searchResults = []
+		},
+		onSearch() {
+			if (!this.keyword.trim()) {
+				this.searchResults = []
+				return
+			}
+			const kw = this.keyword.trim().toLowerCase()
+			const list = this.cityList.length > 0 ? this.cityList : this.defaultCityList
+			this.searchResults = list.filter(city => city.name.toLowerCase().includes(kw) || (city.code && city.code.includes(kw)))
+			if (this.searchResults.length > 0) {
+				this.saveHistory(this.keyword.trim())
+			}
+		},
+		clearSearch() {
+			this.keyword = ''
+			this.searchResults = []
+		},
+		selectCity(city) {
+			this.$emit('input', city)
+			this.$emit('change', city)
+			this.saveHistory(city.name)
+			this.close()
+		},
+		selectCityByName(name) {
+			const list = this.cityList.length > 0 ? this.cityList : this.defaultCityList
+			const city = list.find(c => c.name === name)
+			if (city) {
+				this.selectCity(city)
+			}
+		},
+		isCitySelected(city) {
+			if (!this.value) return false
+			if (typeof this.value === 'string') {
+				return this.value === city.name
+			}
+			return this.value.code === city.code || this.value.name === city.name
+		},
+		saveHistory(name) {
+			if (!name) return
+			let history = uni.getStorageSync('city_search_history') || []
+			history = history.filter(item => item !== name)
+			history.unshift(name)
+			if (history.length > 10) {
+				history = history.slice(0, 10)
+			}
+			uni.setStorageSync('city_search_history', history)
+			this.historyList = history
+		},
+		loadHistory() {
+			this.historyList = uni.getStorageSync('city_search_history') || []
+		},
+		clearHistory() {
+			uni.removeStorageSync('city_search_history')
+			this.historyList = []
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.city-trigger {
+	display: inline-flex;
+	align-items: center;
+}
+
+.city-trigger__default {
+	display: flex;
+	align-items: center;
+	padding: 12rpx 20rpx;
+	background-color: #f5f5f5;
+	border-radius: 30rpx;
+}
+
+.city-trigger__icon {
+	font-size: 28rpx;
+	margin-right: 8rpx;
+}
+
+.city-trigger__text {
+	font-size: 28rpx;
+	color: #333;
+	margin-right: 8rpx;
+}
+
+.city-trigger__arrow {
+	font-size: 20rpx;
+	color: #999;
+}
+
+.city-overlay {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 9990;
+}
+
+.city-overlay__mask {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.5);
+}
+
+.city-sheet {
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: #fff;
+	border-top-left-radius: 24rpx;
+	border-top-right-radius: 24rpx;
+	max-height: 85vh;
+	display: flex;
+	flex-direction: column;
+}
+
+.city-sheet__header {
+	padding: 24rpx 30rpx 20rpx;
+	border-bottom: 1rpx solid #eee;
+}
+
+.city-sheet__title-bar {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 20rpx;
+}
+
+.city-sheet__title {
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #333;
+}
+
+.city-sheet__close {
+	font-size: 40rpx;
+	color: #999;
+	padding: 10rpx;
+}
+
+.city-sheet__search {
+	display: flex;
+	align-items: center;
+	background-color: #f5f5f5;
+	border-radius: 30rpx;
+	padding: 0 20rpx;
+	height: 64rpx;
+}
+
+.city-sheet__search-icon {
+	font-size: 28rpx;
+	margin-right: 12rpx;
+}
+
+.city-sheet__search-input {
+	flex: 1;
+	font-size: 28rpx;
+	color: #333;
+}
+
+.city-sheet__placeholder {
+	color: #999;
+}
+
+.city-sheet__search-clear {
+	font-size: 32rpx;
+	color: #999;
+	padding: 10rpx;
+}
+
+.city-sheet__list {
+	flex: 1;
+}
+
+.city-sheet__loading,
+.city-sheet__empty {
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	padding: 60rpx 0;
+}
+
+.city-sheet__loading-text,
+.city-sheet__empty-text {
+	font-size: 28rpx;
+	color: #999;
+}
+
+.city-section {
+	padding: 24rpx 30rpx;
+}
+
+.city-section__header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 20rpx;
+}
+
+.city-section__title {
+	font-size: 28rpx;
+	font-weight: 600;
+	color: #333;
+}
+
+.city-section__clear {
+	font-size: 24rpx;
+	color: #666;
+}
+
+.city-tags {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 16rpx;
+}
+
+.city-tag {
+	padding: 12rpx 24rpx;
+	background-color: #f5f5f5;
+	border-radius: 30rpx;
+}
+
+.city-tag__text {
+	font-size: 26rpx;
+	color: #333;
+}
+
+.city-group {
+	margin-bottom: 20rpx;
+}
+
+.city-group__letter {
+	padding: 12rpx 30rpx;
+	font-size: 24rpx;
+	color: #999;
+	background-color: #fafafa;
+	position: sticky;
+	top: 0;
+	z-index: 1;
+}
+
+.city-list__item {
+	display: flex;
+	align-items: center;
+	padding: 24rpx 30rpx;
+	border-bottom: 1rpx solid #f5f5f5;
+}
+
+.city-list__item--active {
+	background-color: #f0f7ff;
+}
+
+.city-list__name {
+	flex: 1;
+	font-size: 28rpx;
+	color: #333;
+}
+
+.city-list__code {
+	font-size: 24rpx;
+	color: #999;
+	margin-right: 16rpx;
+}
+
+.city-list__check {
+	font-size: 28rpx;
+	color: #007aff;
+}
+</style>

+ 501 - 0
components/travel/date-range-picker/date-range-picker.vue

@@ -0,0 +1,501 @@
+<template>
+	<view>
+		<view class="date-trigger" @tap="open">
+			<slot>
+				<view class="date-trigger__default">
+					<view class="date-trigger__item">
+						<text class="date-trigger__label">入住</text>
+						<text class="date-trigger__value">{{ checkInText || '请选择' }}</text>
+					</view>
+					<view class="date-trigger__divider"></view>
+					<view class="date-trigger__item">
+						<text class="date-trigger__label">离店</text>
+						<text class="date-trigger__value">{{ checkOutText || '请选择' }}</text>
+					</view>
+					<text class="date-trigger__arrow">▾</text>
+				</view>
+			</slot>
+		</view>
+
+		<view v-if="visible" class="date-overlay">
+			<view class="date-overlay__mask" @tap="close" />
+			<view class="date-sheet" :style="{ paddingBottom: bottomGap + 'px' }" @tap.stop>
+				<view class="date-sheet__header">
+					<view class="date-sheet__title-bar">
+						<text class="date-sheet__title">{{ title }}</text>
+						<text class="date-sheet__close" @tap="close">×</text>
+					</view>
+					<view class="date-sheet__summary">
+						<text v-if="checkInDate" class="date-sheet__summary-text">
+							入住: {{ checkInText }}
+						</text>
+						<text v-if="checkOutDate" class="date-sheet__summary-text">
+							离店: {{ checkOutText }}
+						</text>
+						<text v-if="checkInDate && checkOutDate" class="date-sheet__nights">
+							共 {{ nights }} 晚
+						</text>
+					</view>
+				</view>
+
+				<view class="date-sheet__content">
+					<view class="date-calendar">
+						<view class="date-calendar__header">
+							<view class="date-calendar__nav" @tap="prevMonth">
+								<text class="date-calendar__nav-btn">‹</text>
+							</view>
+							<text class="date-calendar__month">{{ currentYear }}年{{ currentMonth }}月</text>
+							<view class="date-calendar__nav" @tap="nextMonth">
+								<text class="date-calendar__nav-btn">›</text>
+							</view>
+						</view>
+
+						<view class="date-calendar__weekdays">
+							<text v-for="day in weekDays" :key="day" class="date-calendar__weekday">{{ day }}</text>
+						</view>
+
+						<view class="date-calendar__days">
+							<view
+								v-for="(day, index) in calendarDays"
+								:key="index"
+								class="date-calendar__day"
+								:class="{
+									'date-calendar__day--disabled': day.disabled,
+									'date-calendar__day--checkin': isCheckIn(day),
+									'date-calendar__day--checkout': isCheckOut(day),
+									'date-calendar__day--range': isInRange(day),
+									'date-calendar__day--today': isToday(day)
+								}"
+								@tap="selectDay(day)"
+							>
+								<text class="date-calendar__day-text">{{ day.date }}</text>
+							</view>
+						</view>
+					</view>
+				</view>
+
+				<view class="date-sheet__footer">
+					<view class="date-sheet__confirm-btn" @tap="confirm">
+						<text class="date-sheet__confirm-text">确认选择</text>
+					</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'DateRangePicker',
+	props: {
+		checkIn: { type: String, default: '' },
+		checkOut: { type: String, default: '' },
+		title: { type: String, default: '选择日期' },
+		minDate: { type: String, default: '' },
+		maxDate: { type: String, default: '' },
+		minNights: { type: Number, default: 1 },
+		maxNights: { type: Number, default: 30 }
+	},
+	data() {
+		return {
+			visible: false,
+			bottomGap: 0,
+			checkInDate: '',
+			checkOutDate: '',
+			currentYear: new Date().getFullYear(),
+			currentMonth: new Date().getMonth() + 1,
+			weekDays: ['日', '一', '二', '三', '四', '五', '六']
+		}
+	},
+	computed: {
+		checkInText() {
+			return this.checkInDate ? this.formatDate(this.checkInDate) : ''
+		},
+		checkOutText() {
+			return this.checkOutDate ? this.formatDate(this.checkOutDate) : ''
+		},
+		nights() {
+			if (!this.checkInDate || !this.checkOutDate) return 0
+			const start = new Date(this.checkInDate)
+			const end = new Date(this.checkOutDate)
+			return Math.round((end - start) / (1000 * 60 * 60 * 24))
+		},
+		calendarDays() {
+			const days = []
+			const firstDay = new Date(this.currentYear, this.currentMonth - 1, 1)
+			const lastDay = new Date(this.currentYear, this.currentMonth, 0)
+			const startWeekday = firstDay.getDay()
+			const totalDays = lastDay.getDate()
+			const minDate = this.minDate ? new Date(this.minDate) : new Date()
+			const maxDate = this.maxDate ? new Date(this.maxDate) : new Date(this.currentYear, this.currentMonth + 2, 0)
+
+			for (let i = 0; i < startWeekday; i++) {
+				days.push({ date: 0, disabled: true, fullDate: '' })
+			}
+
+			for (let i = 1; i <= totalDays; i++) {
+				const fullDate = `${this.currentYear}-${String(this.currentMonth).padStart(2, '0')}-${String(i).padStart(2, '0')}`
+				const currentDate = new Date(fullDate)
+				const disabled = currentDate < minDate || currentDate > maxDate
+				days.push({
+					date: i,
+					disabled,
+					fullDate,
+					timestamp: currentDate.getTime()
+				})
+			}
+
+			return days
+		}
+	},
+	created() {
+		if (this.checkIn) {
+			this.checkInDate = this.checkIn
+		}
+		if (this.checkOut) {
+			this.checkOutDate = this.checkOut
+		}
+	},
+	methods: {
+		open() {
+			this.visible = true
+			const sys = uni.getSystemInfoSync()
+			const insetBottom = (sys.safeAreaInsets && sys.safeAreaInsets.bottom) || 0
+			this.bottomGap = insetBottom
+		},
+		close() {
+			this.visible = false
+		},
+		prevMonth() {
+			if (this.currentMonth === 1) {
+				this.currentYear--
+				this.currentMonth = 12
+			} else {
+				this.currentMonth--
+			}
+		},
+		nextMonth() {
+			if (this.currentMonth === 12) {
+				this.currentYear++
+				this.currentMonth = 1
+			} else {
+				this.currentMonth++
+			}
+		},
+		selectDay(day) {
+			if (day.disabled) return
+
+			if (!this.checkInDate || (this.checkInDate && this.checkOutDate)) {
+				this.checkInDate = day.fullDate
+				this.checkOutDate = ''
+			} else {
+				const checkIn = new Date(this.checkInDate)
+				const selected = new Date(day.fullDate)
+				const nights = Math.round((selected - checkIn) / (1000 * 60 * 60 * 24))
+
+				if (nights < this.minNights) {
+					uni.showToast({
+						title: `至少入住${this.minNights}晚`,
+						icon: 'none'
+					})
+					return
+				}
+
+				if (nights > this.maxNights) {
+					uni.showToast({
+						title: `最多入住${this.maxNights}晚`,
+						icon: 'none'
+					})
+					return
+				}
+
+				if (selected < checkIn) {
+					this.checkInDate = day.fullDate
+					this.checkOutDate = ''
+				} else {
+					this.checkOutDate = day.fullDate
+				}
+			}
+		},
+		isCheckIn(day) {
+			return day.fullDate === this.checkInDate
+		},
+		isCheckOut(day) {
+			return day.fullDate === this.checkOutDate
+		},
+		isInRange(day) {
+			if (!this.checkInDate || !this.checkOutDate) return false
+			const timestamp = day.timestamp
+			const checkIn = new Date(this.checkInDate).getTime()
+			const checkOut = new Date(this.checkOutDate).getTime()
+			return timestamp > checkIn && timestamp < checkOut
+		},
+		isToday(day) {
+			if (!day.fullDate) return false
+			const today = new Date()
+			const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
+			return day.fullDate === todayStr
+		},
+		confirm() {
+			if (!this.checkInDate) {
+				uni.showToast({
+					title: '请选择入住日期',
+					icon: 'none'
+				})
+				return
+			}
+			if (!this.checkOutDate) {
+				uni.showToast({
+					title: '请选择离店日期',
+					icon: 'none'
+				})
+				return
+			}
+			this.$emit('confirm', {
+				checkIn: this.checkInDate,
+				checkOut: this.checkOutDate,
+				nights: this.nights
+			})
+			this.close()
+		},
+		formatDate(dateStr) {
+			if (!dateStr) return ''
+			const date = new Date(dateStr)
+			const month = date.getMonth() + 1
+			const day = date.getDate()
+			const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
+			const weekDay = weekDays[date.getDay()]
+			return `${month}月${day}日 ${weekDay}`
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.date-trigger {
+	display: inline-flex;
+	align-items: center;
+}
+
+.date-trigger__default {
+	display: flex;
+	align-items: center;
+	padding: 12rpx 20rpx;
+	background-color: #f5f5f5;
+	border-radius: 30rpx;
+}
+
+.date-trigger__item {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	padding: 0 16rpx;
+}
+
+.date-trigger__label {
+	font-size: 20rpx;
+	color: #999;
+	margin-bottom: 4rpx;
+}
+
+.date-trigger__value {
+	font-size: 26rpx;
+	color: #333;
+	font-weight: 500;
+}
+
+.date-trigger__divider {
+	width: 1rpx;
+	height: 40rpx;
+	background-color: #ddd;
+	margin: 0 8rpx;
+}
+
+.date-trigger__arrow {
+	font-size: 20rpx;
+	color: #999;
+	margin-left: 8rpx;
+}
+
+.date-overlay {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 9990;
+}
+
+.date-overlay__mask {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.5);
+}
+
+.date-sheet {
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: #fff;
+	border-top-left-radius: 24rpx;
+	border-top-right-radius: 24rpx;
+	max-height: 85vh;
+	display: flex;
+	flex-direction: column;
+}
+
+.date-sheet__header {
+	padding: 24rpx 30rpx 20rpx;
+	border-bottom: 1rpx solid #eee;
+}
+
+.date-sheet__title-bar {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 16rpx;
+}
+
+.date-sheet__title {
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #333;
+}
+
+.date-sheet__close {
+	font-size: 40rpx;
+	color: #999;
+	padding: 10rpx;
+}
+
+.date-sheet__summary {
+	display: flex;
+	align-items: center;
+	gap: 20rpx;
+	flex-wrap: wrap;
+}
+
+.date-sheet__summary-text {
+	font-size: 26rpx;
+	color: #666;
+}
+
+.date-sheet__nights {
+	font-size: 26rpx;
+	color: #007aff;
+	font-weight: 500;
+}
+
+.date-sheet__content {
+	flex: 1;
+	overflow-y: auto;
+	padding: 20rpx 0;
+}
+
+.date-calendar {
+	padding: 0 30rpx;
+}
+
+.date-calendar__header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 20rpx;
+}
+
+.date-calendar__nav-btn {
+	font-size: 40rpx;
+	color: #333;
+	padding: 10rpx 20rpx;
+}
+
+.date-calendar__month {
+	font-size: 30rpx;
+	font-weight: 600;
+	color: #333;
+}
+
+.date-calendar__weekdays {
+	display: flex;
+	justify-content: space-around;
+	margin-bottom: 16rpx;
+}
+
+.date-calendar__weekday {
+	flex: 1;
+	text-align: center;
+	font-size: 24rpx;
+	color: #999;
+	padding: 10rpx 0;
+}
+
+.date-calendar__days {
+	display: flex;
+	flex-wrap: wrap;
+}
+
+.date-calendar__day {
+	width: 14.28%;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	padding: 16rpx 0;
+}
+
+.date-calendar__day-text {
+	width: 60rpx;
+	height: 60rpx;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	font-size: 28rpx;
+	color: #333;
+	border-radius: 50%;
+}
+
+.date-calendar__day--disabled {
+	opacity: 0.3;
+}
+
+.date-calendar__day--disabled .date-calendar__day-text {
+	color: #ccc;
+}
+
+.date-calendar__day--today .date-calendar__day-text {
+	border: 2rpx solid #007aff;
+}
+
+.date-calendar__day--checkin .date-calendar__day-text,
+.date-calendar__day--checkout .date-calendar__day-text {
+	background-color: #007aff;
+	color: #fff;
+}
+
+.date-calendar__day--range .date-calendar__day-text {
+	background-color: #e6f2ff;
+	color: #007aff;
+}
+
+.date-sheet__footer {
+	padding: 20rpx 30rpx;
+	padding-bottom: 40rpx;
+	border-top: 1rpx solid #eee;
+}
+
+.date-sheet__confirm-btn {
+	background-color: #007aff;
+	border-radius: 40rpx;
+	padding: 24rpx 0;
+	text-align: center;
+}
+
+.date-sheet__confirm-text {
+	font-size: 30rpx;
+	color: #fff;
+	font-weight: 500;
+}
+</style>

+ 258 - 0
components/travel/hotel-card/hotel-card.vue

@@ -0,0 +1,258 @@
+<template>
+	<view class="hotel-card" @tap="onTap">
+		<view class="hotel-card__image-wrap">
+			<image 
+				class="hotel-card__image" 
+				:src="hotel.image || hotel.images[0]" 
+				mode="aspectFill"
+			/>
+			<view v-if="hotel.isRecommend" class="hotel-card__tag hotel-card__tag--recommend">
+				<text class="hotel-card__tag-text">推荐</text>
+			</view>
+			<view v-if="hotel.discount" class="hotel-card__tag hotel-card__tag--discount">
+				<text class="hotel-card__tag-text">{{ hotel.discount }}折</text>
+			</view>
+		</view>
+		
+		<view class="hotel-card__content">
+			<view class="hotel-card__header">
+				<text class="hotel-card__name">{{ hotel.name }}</text>
+				<view v-if="hotel.stars" class="hotel-card__stars">
+					<text v-for="i in hotel.stars" :key="i" class="hotel-card__star">★</text>
+				</view>
+			</view>
+			
+			<view class="hotel-card__info">
+				<text class="hotel-card__address">📍 {{ hotel.address }}</text>
+				<text v-if="hotel.distance" class="hotel-card__distance">距{{ hotel.distanceTarget }}{{ hotel.distance }}</text>
+			</view>
+			
+			<view class="hotel-card__tags">
+				<text v-for="(tag, index) in hotel.tags" :key="index" class="hotel-card__tag-item">
+					{{ tag }}
+				</text>
+			</view>
+			
+			<view class="hotel-card__footer">
+				<view class="hotel-card__score">
+					<text class="hotel-card__score-value">{{ hotel.score || hotel.rating }}</text>
+					<text class="hotel-card__score-label">{{ hotel.scoreText || '分' }}</text>
+					<text v-if="hotel.reviewCount" class="hotel-card__reviews">{{ hotel.reviewCount }}条评价</text>
+				</view>
+				<view class="hotel-card__price">
+					<text class="hotel-card__price-symbol">¥</text>
+					<text class="hotel-card__price-value">{{ hotel.price }}</text>
+					<text class="hotel-card__price-unit">起</text>
+					<text v-if="hotel.originalPrice" class="hotel-card__price-original">¥{{ hotel.originalPrice }}</text>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'HotelCard',
+	props: {
+		hotel: {
+			type: Object,
+			required: true,
+			default: () => ({
+				name: '',
+				image: '',
+				images: [],
+				address: '',
+				price: 0,
+				stars: 0,
+				score: 0,
+				tags: [],
+				isRecommend: false,
+				discount: 0,
+				distance: '',
+				distanceTarget: '',
+				originalPrice: 0,
+				reviewCount: 0,
+				scoreText: '分'
+			})
+		}
+	},
+	methods: {
+		onTap() {
+			this.$emit('tap', this.hotel)
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.hotel-card {
+	background-color: #fff;
+	border-radius: 16rpx;
+	overflow: hidden;
+	margin-bottom: 24rpx;
+	box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
+}
+
+.hotel-card__image-wrap {
+	position: relative;
+	width: 100%;
+	height: 360rpx;
+}
+
+.hotel-card__image {
+	width: 100%;
+	height: 100%;
+}
+
+.hotel-card__tag {
+	position: absolute;
+	top: 16rpx;
+	padding: 6rpx 16rpx;
+	border-radius: 20rpx;
+}
+
+.hotel-card__tag--recommend {
+	left: 16rpx;
+	background-color: #ff6b6b;
+}
+
+.hotel-card__tag--discount {
+	right: 16rpx;
+	background-color: #ffa940;
+}
+
+.hotel-card__tag-text {
+	font-size: 22rpx;
+	color: #fff;
+	font-weight: 500;
+}
+
+.hotel-card__content {
+	padding: 24rpx;
+}
+
+.hotel-card__header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 12rpx;
+}
+
+.hotel-card__name {
+	flex: 1;
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #333;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.hotel-card__stars {
+	display: flex;
+	align-items: center;
+	margin-left: 12rpx;
+}
+
+.hotel-card__star {
+	font-size: 24rpx;
+	color: #ffa940;
+}
+
+.hotel-card__info {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 12rpx;
+}
+
+.hotel-card__address {
+	flex: 1;
+	font-size: 24rpx;
+	color: #666;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.hotel-card__distance {
+	font-size: 22rpx;
+	color: #007aff;
+	margin-left: 16rpx;
+	white-space: nowrap;
+}
+
+.hotel-card__tags {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 12rpx;
+	margin-bottom: 16rpx;
+}
+
+.hotel-card__tag-item {
+	padding: 4rpx 12rpx;
+	background-color: #f5f5f5;
+	border-radius: 8rpx;
+	font-size: 22rpx;
+	color: #666;
+}
+
+.hotel-card__footer {
+	display: flex;
+	justify-content: space-between;
+	align-items: flex-end;
+}
+
+.hotel-card__score {
+	display: flex;
+	align-items: baseline;
+}
+
+.hotel-card__score-value {
+	font-size: 36rpx;
+	font-weight: 700;
+	color: #007aff;
+	margin-right: 8rpx;
+}
+
+.hotel-card__score-label {
+	font-size: 22rpx;
+	color: #999;
+	margin-right: 12rpx;
+}
+
+.hotel-card__reviews {
+	font-size: 22rpx;
+	color: #999;
+}
+
+.hotel-card__price {
+	display: flex;
+	align-items: baseline;
+}
+
+.hotel-card__price-symbol {
+	font-size: 24rpx;
+	color: #ff6b6b;
+	font-weight: 600;
+}
+
+.hotel-card__price-value {
+	font-size: 40rpx;
+	color: #ff6b6b;
+	font-weight: 700;
+	margin: 0 4rpx;
+}
+
+.hotel-card__price-unit {
+	font-size: 22rpx;
+	color: #999;
+}
+
+.hotel-card__price-original {
+	font-size: 22rpx;
+	color: #999;
+	text-decoration: line-through;
+	margin-left: 12rpx;
+}
+</style>

+ 438 - 0
components/travel/passenger-picker/passenger-picker.vue

@@ -0,0 +1,438 @@
+<template>
+	<view>
+		<view class="passenger-trigger" @tap="open">
+			<slot>
+				<view class="passenger-trigger__default">
+					<view class="passenger-trigger__item">
+						<text class="passenger-trigger__label">房间</text>
+						<text class="passenger-trigger__value">{{ roomCount }}间</text>
+					</view>
+					<view class="passenger-trigger__divider"></view>
+					<view class="passenger-trigger__item">
+						<text class="passenger-trigger__label">成人</text>
+						<text class="passenger-trigger__value">{{ adultCount }}人</text>
+					</view>
+					<view class="passenger-trigger__divider"></view>
+					<view class="passenger-trigger__item">
+						<text class="passenger-trigger__label">儿童</text>
+						<text class="passenger-trigger__value">{{ childCount }}人</text>
+					</view>
+					<text class="passenger-trigger__arrow">▾</text>
+				</view>
+			</slot>
+		</view>
+
+		<view v-if="visible" class="passenger-overlay">
+			<view class="passenger-overlay__mask" @tap="close" />
+			<view class="passenger-sheet" :style="{ paddingBottom: bottomGap + 'px' }" @tap.stop>
+				<view class="passenger-sheet__header">
+					<view class="passenger-sheet__title-bar">
+						<text class="passenger-sheet__title">{{ title }}</text>
+						<text class="passenger-sheet__close" @tap="close">×</text>
+					</view>
+				</view>
+
+				<view class="passenger-sheet__content">
+					<view class="passenger-row">
+						<view class="passenger-row__info">
+							<text class="passenger-row__title">房间数</text>
+							<text class="passenger-row__desc">共{{ roomCount }}间</text>
+						</view>
+						<view class="passenger-row__counter">
+							<view 
+								class="passenger-row__btn" 
+								:class="{ 'passenger-row__btn--disabled': roomCount <= minRoom }"
+								@tap="decreaseRoom"
+							>
+								<text class="passenger-row__btn-text">-</text>
+							</view>
+							<text class="passenger-row__count">{{ roomCount }}</text>
+							<view 
+								class="passenger-row__btn" 
+								:class="{ 'passenger-row__btn--disabled': roomCount >= maxRoom }"
+								@tap="increaseRoom"
+							>
+								<text class="passenger-row__btn-text">+</text>
+							</view>
+						</view>
+					</view>
+
+					<view class="passenger-divider"></view>
+
+					<view class="passenger-row">
+						<view class="passenger-row__info">
+							<text class="passenger-row__title">成人</text>
+							<text class="passenger-row__desc">12岁以上</text>
+						</view>
+						<view class="passenger-row__counter">
+							<view 
+								class="passenger-row__btn" 
+								:class="{ 'passenger-row__btn--disabled': adultCount <= minAdult }"
+								@tap="decreaseAdult"
+							>
+								<text class="passenger-row__btn-text">-</text>
+							</view>
+							<text class="passenger-row__count">{{ adultCount }}</text>
+							<view 
+								class="passenger-row__btn" 
+								:class="{ 'passenger-row__btn--disabled': adultCount >= maxAdult }"
+								@tap="increaseAdult"
+							>
+								<text class="passenger-row__btn-text">+</text>
+							</view>
+						</view>
+					</view>
+
+					<view class="passenger-divider"></view>
+
+					<view class="passenger-row">
+						<view class="passenger-row__info">
+							<text class="passenger-row__title">儿童</text>
+							<text class="passenger-row__desc">2-12岁</text>
+						</view>
+						<view class="passenger-row__counter">
+							<view 
+								class="passenger-row__btn" 
+								:class="{ 'passenger-row__btn--disabled': childCount <= minChild }"
+								@tap="decreaseChild"
+							>
+								<text class="passenger-row__btn-text">-</text>
+							</view>
+							<text class="passenger-row__count">{{ childCount }}</text>
+							<view 
+								class="passenger-row__btn" 
+								:class="{ 'passenger-row__btn--disabled': childCount >= maxChild }"
+								@tap="increaseChild"
+							>
+								<text class="passenger-row__btn-text">+</text>
+							</view>
+						</view>
+					</view>
+
+					<view class="passenger-divider"></view>
+
+					<view class="passenger-row">
+						<view class="passenger-row__info">
+							<text class="passenger-row__title">婴儿</text>
+							<text class="passenger-row__desc">2岁以下(不占床)</text>
+						</view>
+						<view class="passenger-row__counter">
+							<view 
+								class="passenger-row__btn" 
+								:class="{ 'passenger-row__btn--disabled': infantCount <= minInfant }"
+								@tap="decreaseInfant"
+							>
+								<text class="passenger-row__btn-text">-</text>
+							</view>
+							<text class="passenger-row__count">{{ infantCount }}</text>
+							<view 
+								class="passenger-row__btn" 
+								:class="{ 'passenger-row__btn--disabled': infantCount >= maxInfant }"
+								@tap="increaseInfant"
+							>
+								<text class="passenger-row__btn-text">+</text>
+							</view>
+						</view>
+					</view>
+				</view>
+
+				<view class="passenger-sheet__footer">
+					<view class="passenger-sheet__confirm-btn" @tap="confirm">
+						<text class="passenger-sheet__confirm-text">确认</text>
+					</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'PassengerPicker',
+	props: {
+		title: { type: String, default: '房间和人数' },
+		minRoom: { type: Number, default: 1 },
+		maxRoom: { type: Number, default: 10 },
+		minAdult: { type: Number, default: 1 },
+		maxAdult: { type: Number, default: 10 },
+		minChild: { type: Number, default: 0 },
+		maxChild: { type: Number, default: 5 },
+		minInfant: { type: Number, default: 0 },
+		maxInfant: { type: Number, default: 3 },
+		defaultRoom: { type: Number, default: 1 },
+		defaultAdult: { type: Number, default: 2 },
+		defaultChild: { type: Number, default: 0 },
+		defaultInfant: { type: Number, default: 0 }
+	},
+	data() {
+		return {
+			visible: false,
+			bottomGap: 0,
+			roomCount: 1,
+			adultCount: 2,
+			childCount: 0,
+			infantCount: 0
+		}
+	},
+	created() {
+		this.roomCount = this.defaultRoom
+		this.adultCount = this.defaultAdult
+		this.childCount = this.defaultChild
+		this.infantCount = this.defaultInfant
+	},
+	methods: {
+		open() {
+			this.visible = true
+			const sys = uni.getSystemInfoSync()
+			const insetBottom = (sys.safeAreaInsets && sys.safeAreaInsets.bottom) || 0
+			this.bottomGap = insetBottom
+		},
+		close() {
+			this.visible = false
+		},
+		increaseRoom() {
+			if (this.roomCount < this.maxRoom) {
+				this.roomCount++
+				if (this.adultCount < this.roomCount) {
+					this.adultCount = this.roomCount
+				}
+			}
+		},
+		decreaseRoom() {
+			if (this.roomCount > this.minRoom) {
+				this.roomCount--
+			}
+		},
+		increaseAdult() {
+			if (this.adultCount < this.maxAdult) {
+				this.adultCount++
+			}
+		},
+		decreaseAdult() {
+			if (this.adultCount > this.minAdult) {
+				this.adultCount--
+			}
+		},
+		increaseChild() {
+			if (this.childCount < this.maxChild) {
+				this.childCount++
+			}
+		},
+		decreaseChild() {
+			if (this.childCount > this.minChild) {
+				this.childCount--
+			}
+		},
+		increaseInfant() {
+			if (this.infantCount < this.maxInfant) {
+				this.infantCount++
+			}
+		},
+		decreaseInfant() {
+			if (this.infantCount > this.minInfant) {
+				this.infantCount--
+			}
+		},
+		confirm() {
+			this.$emit('confirm', {
+				room: this.roomCount,
+				adult: this.adultCount,
+				child: this.childCount,
+				infant: this.infantCount
+			})
+			this.close()
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.passenger-trigger {
+	display: inline-flex;
+	align-items: center;
+}
+
+.passenger-trigger__default {
+	display: flex;
+	align-items: center;
+	padding: 12rpx 20rpx;
+	background-color: #f5f5f5;
+	border-radius: 30rpx;
+}
+
+.passenger-trigger__item {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	padding: 0 16rpx;
+}
+
+.passenger-trigger__label {
+	font-size: 20rpx;
+	color: #999;
+	margin-bottom: 4rpx;
+}
+
+.passenger-trigger__value {
+	font-size: 26rpx;
+	color: #333;
+	font-weight: 500;
+}
+
+.passenger-trigger__divider {
+	width: 1rpx;
+	height: 40rpx;
+	background-color: #ddd;
+	margin: 0 8rpx;
+}
+
+.passenger-trigger__arrow {
+	font-size: 20rpx;
+	color: #999;
+	margin-left: 8rpx;
+}
+
+.passenger-overlay {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 9990;
+}
+
+.passenger-overlay__mask {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.5);
+}
+
+.passenger-sheet {
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: #fff;
+	border-top-left-radius: 24rpx;
+	border-top-right-radius: 24rpx;
+	max-height: 70vh;
+	display: flex;
+	flex-direction: column;
+}
+
+.passenger-sheet__header {
+	padding: 24rpx 30rpx 20rpx;
+	border-bottom: 1rpx solid #eee;
+}
+
+.passenger-sheet__title-bar {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+}
+
+.passenger-sheet__title {
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #333;
+}
+
+.passenger-sheet__close {
+	font-size: 40rpx;
+	color: #999;
+	padding: 10rpx;
+}
+
+.passenger-sheet__content {
+	padding: 20rpx 30rpx;
+	flex: 1;
+	overflow-y: auto;
+}
+
+.passenger-row {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	padding: 20rpx 0;
+}
+
+.passenger-row__info {
+	flex: 1;
+}
+
+.passenger-row__title {
+	display: block;
+	font-size: 30rpx;
+	color: #333;
+	font-weight: 500;
+	margin-bottom: 8rpx;
+}
+
+.passenger-row__desc {
+	display: block;
+	font-size: 24rpx;
+	color: #999;
+}
+
+.passenger-row__counter {
+	display: flex;
+	align-items: center;
+	gap: 24rpx;
+}
+
+.passenger-row__btn {
+	width: 56rpx;
+	height: 56rpx;
+	border-radius: 50%;
+	background-color: #f5f5f5;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+}
+
+.passenger-row__btn--disabled {
+	opacity: 0.4;
+}
+
+.passenger-row__btn-text {
+	font-size: 36rpx;
+	color: #333;
+	font-weight: 500;
+}
+
+.passenger-row__count {
+	font-size: 32rpx;
+	color: #333;
+	font-weight: 600;
+	min-width: 48rpx;
+	text-align: center;
+}
+
+.passenger-divider {
+	height: 1rpx;
+	background-color: #eee;
+	margin: 10rpx 0;
+}
+
+.passenger-sheet__footer {
+	padding: 20rpx 30rpx;
+	padding-bottom: 40rpx;
+	border-top: 1rpx solid #eee;
+}
+
+.passenger-sheet__confirm-btn {
+	background-color: #007aff;
+	border-radius: 40rpx;
+	padding: 24rpx 0;
+	text-align: center;
+}
+
+.passenger-sheet__confirm-text {
+	font-size: 30rpx;
+	color: #fff;
+	font-weight: 500;
+}
+</style>

File diff suppressed because it is too large
+ 19 - 0
components/uni-load-more/uni-load-more.vue


+ 319 - 0
components/uni-popup/live-video-paypop.vue

@@ -0,0 +1,319 @@
+<template>
+	<view v-if="show" class="pay-popup-overlay" @tap="handleMaskClick">
+		<view class="pay-popup-modal" @tap.stop="">
+			<!-- 图标 -->
+			<view v-if="icon || showDefaultIcon" class="pay-popup-icon-container">
+				<view class="pay-popup-icon-wrapper">
+					<!-- 如果传入了图标路径,显示图片 -->
+					<image 
+						v-if="icon" 
+						class="pay-popup-icon" 
+						:src="icon" 
+						mode="aspectFit"
+					></image>
+					<!-- 否则显示默认渐变背景 -->
+					<view v-else class="pay-popup-icon-gradient"></view>
+				</view>
+			</view>
+			
+			<!-- 标题 -->
+			<text v-if="titleText" class="pay-popup-title">{{ titleText }}</text>
+			
+			<!-- 副标题/文字内容 -->
+			<text v-if="subtitle" class="pay-popup-subtitle">{{ subtitle }}</text>
+			
+			<!-- 按钮容器 -->
+			<view class="pay-popup-buttons">
+				<!-- 关闭按钮 -->
+				<view class="pay-popup-button pay-popup-button-close" :style="buttonCloseStyle" @tap="handleClose">
+					<text class="pay-popup-button-text pay-popup-button-text-close">{{ closeText }}</text>
+				</view>
+				<!-- 确认支付按钮 -->
+				<view 
+					class="pay-popup-button pay-popup-button-confirm" 
+					:style="buttonConfirmStyle"
+					@tap="handleConfirm"
+				>
+					<text class="pay-popup-button-text pay-popup-button-text-confirm">{{ confirmText }}</text>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'LiveVideoPayPop',
+	props: {
+		// 是否显示
+		show: {
+			type: Boolean,
+			default: false
+		},
+		// 图标路径(如果传入则显示图片,否则显示默认渐变背景)
+		icon: {
+			type: String,
+			default: ''
+		},
+		// 是否显示默认图标(当icon为空时,是否显示默认渐变图标)
+		showDefaultIcon: {
+			type: Boolean,
+			default: true
+		},
+		// 支付金额(钻石数)
+		amount: {
+			type: [Number, String],
+			default: 0
+		},
+		// 标题(如果不传则使用默认格式:支付{amount}钻石,如果传入空字符串则不显示标题)
+		title: {
+			type: String,
+			default: ''
+		},
+		// 副标题/文字内容(如果传入空字符串则不显示)
+		subtitle: {
+			type: String,
+			default: '解锁观看完整剧集'
+		},
+		// 关闭按钮文字
+		closeText: {
+			type: String,
+			default: '关闭'
+		},
+		// 确认按钮文字
+		confirmText: {
+			type: String,
+			default: '确认支付'
+		},
+		// 点击遮罩是否关闭
+		maskClosable: {
+			type: Boolean,
+			default: true
+		}
+	},
+	computed: {
+		// 标题文本(如果title为空字符串,返回null不显示;如果title有值则使用,否则使用默认格式)
+		titleText() {
+			if (this.title === '') {
+				// 如果明确传入空字符串,则不显示标题
+				return null;
+			}
+			if (this.title) {
+				return this.title;
+			}
+			// 如果title未设置,使用默认格式
+			return `支付${this.amount}钻石`;
+		},
+		// 关闭按钮样式(nvue中需要使用内联样式设置间距)
+		buttonCloseStyle() {
+			// #ifdef APP-NVUE
+			// nvue中使用flex: 1平分空间,通过marginRight设置间距
+			return {
+				marginRight: '20rpx'
+			};
+			// #endif
+			// #ifndef APP-NVUE
+			return {};
+			// #endif
+		},
+		// 确认按钮样式(nvue中需要使用内联样式)
+		buttonConfirmStyle() {
+			// #ifdef APP-NVUE
+			return {
+				backgroundColor: '#19be6b'
+			};
+			// #endif
+			// #ifndef APP-NVUE
+			return {};
+			// #endif
+		}
+	},
+	methods: {
+		handleMaskClick() {
+			if (this.maskClosable) {
+				this.$emit('close');
+			}
+		},
+		handleClose() {
+			this.$emit('close');
+		},
+		handleConfirm() {
+			this.$emit('confirm', {
+				amount: this.amount
+			});
+		}
+	}
+};
+</script>
+
+<style scoped>
+.pay-popup-overlay {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.5);
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+	z-index: 1000;
+}
+
+.pay-popup-modal {
+	background-color: #ffffff;
+	border-radius: 32rpx;
+	width: 600rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.2);
+	/* #endif */
+	flex-direction: column;
+	align-items: center;
+	padding: 60rpx 40rpx 40rpx;
+}
+
+.pay-popup-icon-container {
+	margin-bottom: 40rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+}
+
+.pay-popup-icon-wrapper {
+	width: 200rpx;
+	height: 200rpx;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	align-items: center;
+	justify-content: center;
+}
+
+.pay-popup-icon-gradient {
+	width: 250rpx;
+	height: 200rpx;
+	border-radius: 100rpx;
+	/* #ifndef APP-NVUE */
+	background: linear-gradient(135deg, #ffd700 0%, #4a90e2 50%, #ff69b4 100%);
+	/* 添加闪光效果 */
+	box-shadow: 
+		0 0 20rpx rgba(255, 215, 0, 0.6),
+		0 0 40rpx rgba(74, 144, 226, 0.4),
+		0 0 60rpx rgba(255, 105, 180, 0.3);
+	/* #endif */
+	/* #ifdef APP-NVUE */
+	/* nvue中使用纯色背景,渐变可能不支持 */
+	background-color: #4a90e2;
+	/* #endif */
+}
+
+.pay-popup-icon {
+	width: 200rpx;
+	height: 200rpx;
+	position: absolute;
+	top: 50%;
+	left: 50%;
+	/* #ifndef APP-NVUE */
+	transform: translate(-50%, -50%);
+	/* #endif */
+	/* #ifdef APP-NVUE */
+	margin-top: -100rpx;
+	margin-left: -100rpx;
+	/* #endif */
+}
+
+.pay-popup-title {
+	color: #333333;
+	font-size: 36rpx;
+	font-weight: bold;
+	margin-bottom: 20rpx;
+	text-align: center;
+	/* #ifndef APP-NVUE */
+	display: block;
+	/* #endif */
+}
+
+.pay-popup-subtitle {
+	color: #666666;
+	font-size: 28rpx;
+	margin-bottom: 50rpx;
+	text-align: center;
+	/* #ifndef APP-NVUE */
+	display: block;
+	/* #endif */
+}
+
+.pay-popup-buttons {
+	width: 444rpx;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	
+	/* #ifndef APP-NVUE */
+	display: flex;
+	gap: 20rpx;
+	/* #endif */
+}
+
+.pay-popup-button {
+	flex: 1;
+	height: 88rpx;
+	border-radius: 44rpx;
+	align-items: center;
+	justify-content: center;
+	/* #ifndef APP-NVUE */
+	display: flex;
+	/* #endif */
+	/* #ifdef APP-NVUE */
+	/* nvue中确保按钮内容不被裁剪 */
+	overflow: hidden;
+	/* #endif */
+	/* 按钮文字与背景左右间距 */
+	padding-left: 20rpx;
+	padding-right: 20rpx;
+}
+
+/* 关闭按钮 */
+.pay-popup-button-close {
+	background-color: #f5f5f5;
+	border-width: 1rpx;
+	border-style: solid;
+	border-color: #cccccc;
+	/* #ifndef APP-NVUE */
+	box-shadow: none;
+	/* #endif */
+}
+
+.pay-popup-button-text-close {
+	color: #333333;
+	font-size: 32rpx;
+	font-weight: 500;
+}
+
+/* 确认支付按钮 */
+.pay-popup-button-confirm {
+	/* #ifndef APP-NVUE */
+	background: linear-gradient(135deg, #19be6b 0%, #16ab60 100%);
+	box-shadow: 0 8rpx 24rpx rgba(25, 190, 107, 0.4);
+	/* #endif */
+	/* #ifdef APP-NVUE */
+	/* nvue中背景色通过内联样式设置 */
+	border-width: 0;
+	/* #endif */
+}
+
+.pay-popup-button-text-confirm {
+	color: #ffffff;
+	font-size: 32rpx;
+	font-weight: 500;
+	/* #ifdef APP-NVUE */
+	/* nvue中确保文字完整显示 */
+	text-align: center;
+	/* #endif */
+}
+</style>

+ 275 - 0
components/uni-popup/uni-popup.vue

@@ -0,0 +1,275 @@
+<template>
+	<view v-if="showPopup" class="uni-popup" @touchmove.stop.prevent="clear">
+		<uni-transition :mode-class="['fade']" :styles="maskClass" :duration="duration" :show="showTrans" @tap="onTap" />
+		<uni-transition :mode-class="ani" :styles="transClass" :duration="duration" :show="showTrans" @tap="onTap">
+			<view class="uni-popup__wrapper-box" @tap.stop="clear">
+				<slot />
+			</view>
+		</uni-transition>
+	</view>
+</template>
+
+<script>
+	import uniTransition from '../uni-transition/uni-transition.vue'
+
+	/**
+	 * PopUp 弹出层
+	 * @description 弹出层组件,为了解决遮罩弹层的问题
+	 * @tutorial https://ext.dcloud.net.cn/plugin?id=329
+	 * @property {String} type = [top|center|bottom] 弹出方式
+	 * 	@value top 顶部弹出
+	 * 	@value center 中间弹出
+	 * 	@value bottom 底部弹出
+	 * @property {Boolean} animation = [ture|false] 是否开启动画
+	 * @property {Boolean} maskClick = [ture|false] 蒙版点击是否关闭弹窗
+	 * @event {Function} change 打开关闭弹窗触发,e={show: false}
+	 */
+
+	export default {
+		name: 'UniPopup',
+		components: {
+			uniTransition
+		},
+		props: {
+			// 开启动画
+			animation: {
+				type: Boolean,
+				default: true
+			},
+			// 弹出层类型,可选值,top: 顶部弹出层;bottom:底部弹出层;center:全屏弹出层
+			type: {
+				type: String,
+				default: 'center'
+			},
+			// maskClick
+			maskClick: {
+				type: Boolean,
+				default: true
+			}
+		},
+		data() {
+			return {
+				duration: 300,
+				ani: [],
+				showPopup: false,
+				showTrans: false,
+				maskClass: {
+					'position': 'fixed',
+					'bottom': 0,
+					'top': 0,
+					'left': 0,
+					'right': 0,
+					'backgroundColor': 'rgba(0, 0, 0, 0)'
+				},
+				transClass: {
+					'position': 'fixed',
+					'left': 0,
+					'right': 0,
+				}
+			}
+		},
+		watch: {
+			type: {
+				handler: function(newVal) {
+					switch (this.type) {
+						case 'top':
+							this.ani = ['slide-top']
+							this.transClass = {
+								'position': 'fixed',
+								'left': 0,
+								'right': 0,
+							}
+							break
+						case 'bottom':
+							// iOS 下 bottom 弹窗若使用 translateY 位移动画,可能会触发页面整体“上推/顶出”的显示问题
+							// 因此 iOS 特殊处理:改为淡入淡出(不做位移)
+							// #ifdef APP-PLUS
+							if (uni.getSystemInfoSync && uni.getSystemInfoSync().platform === 'ios') {
+								this.ani = ['fade']
+							} else {
+								this.ani = ['slide-bottom']
+							}
+							// #endif
+							// #ifndef APP-PLUS
+							this.ani = ['slide-bottom']
+							// #endif
+							this.transClass = {
+								'position': 'fixed',
+								'left': 0,
+								'right': 0,
+								'bottom': 0
+							}
+							break
+						case 'center':
+							this.ani = ['zoom-out', 'fade']
+							this.transClass = {
+								'position': 'fixed',
+								/* #ifndef APP-NVUE */
+								'display': 'flex',
+								'flexDirection': 'column',
+								/* #endif */
+								'bottom': 0,
+								'left': 0,
+								'right': 0,
+								'top': 0,
+								'justifyContent': 'center',
+								'alignItems': 'center'
+							}
+
+							break
+					}
+				},
+				immediate: true
+			}
+		},
+		created() {
+			if (this.animation) {
+				this.duration = 300
+			} else {
+				this.duration = 0
+			}
+		},
+		methods: {
+			clear(e) {
+				// TODO nvue 取消冒泡
+				e.stopPropagation()
+			},
+			open() {
+				this.showPopup = true
+				this.$nextTick(() => {
+					clearTimeout(this.timer)
+					this.timer = setTimeout(() => {
+						this.showTrans = true
+					}, 50);
+				})
+				this.$emit('change', {
+					show: true
+				})
+			},
+			close(type) {
+				this.showTrans = false
+				this.$nextTick(() => {
+					clearTimeout(this.timer)
+					this.timer = setTimeout(() => {
+						this.$emit('change', {
+							show: false
+						})
+						this.showPopup = false
+					}, 300)
+				})
+			},
+			onTap() {
+				if (!this.maskClick) return
+				this.close()
+			}
+		}
+	}
+</script>
+<style scoped>
+	.uni-popup {
+		position: fixed;
+		/* #ifdef H5 */
+		top: var(--window-top);
+		/* #endif */
+		/* #ifndef H5 */
+		top: 0;
+		/* #endif */
+		bottom: 0;
+		left: 0;
+		right: 0;
+		/* #ifndef APP-NVUE */
+		z-index: 99;
+		/* #endif */
+	}
+
+	.uni-popup__mask {
+		position: absolute;
+		top: 0;
+		bottom: 0;
+		left: 0;
+		right: 0;
+		background-color: rgba(0, 0, 0, 0.4);
+		opacity: 0;
+	}
+
+	.mask-ani {
+		transition-property: opacity;
+		transition-duration: 0.2s;
+	}
+
+	.uni-top-mask {
+		opacity: 1;
+	}
+
+	.uni-bottom-mask {
+		opacity: 1;
+	}
+
+	.uni-center-mask {
+		opacity: 1;
+	}
+
+	.uni-popup__wrapper {
+		/* #ifndef APP-NVUE */
+		display: block;
+		/* #endif */
+		position: absolute;
+	}
+
+	.top {
+		top: 0;
+		left: 0;
+		right: 0;
+		transform: translateY(-500px);
+	}
+
+	.bottom {
+		bottom: 0;
+		left: 0;
+		right: 0;
+		transform: translateY(500px);
+	}
+
+	.center {
+		/* #ifndef APP-NVUE */
+		display: flex;
+		flex-direction: column;
+		/* #endif */
+		bottom: 0;
+		left: 0;
+		right: 0;
+		top: 0;
+		justify-content: center;
+		align-items: center;
+		transform: scale(1.2);
+		opacity: 0;
+	}
+
+	.uni-popup__wrapper-box {
+		/* #ifndef APP-NVUE */
+		display: block;
+		/* #endif */
+		position: relative;
+	}
+
+	.content-ani {
+		/* transition: transform 0.3s;
+ */
+		transition-property: transform, opacity;
+		transition-duration: 0.2s;
+	}
+
+
+	.uni-top-content {
+		transform: translateY(0);
+	}
+
+	.uni-bottom-content {
+		transform: translateY(0);
+	}
+
+	.uni-center-content {
+		transform: scale(1);
+		opacity: 1;
+	}
+</style>

+ 279 - 0
components/uni-transition/uni-transition.vue

@@ -0,0 +1,279 @@
+<template>
+	<view v-if="isShow" ref="ani" class="uni-transition" :class="[ani.in]" :style="'transform:' +transform+';'+stylesObject" @tap="change">
+		<slot></slot>
+	</view>
+</template>
+
+<script>
+	// #ifdef APP-NVUE
+	const animation = uni.requireNativePlugin('animation');
+	// #endif
+	/**
+	 * Transition 过渡动画
+	 * @description 简单过渡动画组件
+	 * @tutorial https://ext.dcloud.net.cn/plugin?id=985
+	 * @property {Boolean} show = [false|true] 控制组件显示或隐藏
+	 * @property {Array} modeClass = [fade|slide-top|slide-right|slide-bottom|slide-left|zoom-in|zoom-out] 过渡动画类型
+	 *  @value fade 渐隐渐出过渡
+	 *  @value slide-top 由上至下过渡
+	 *  @value slide-right 由右至左过渡
+	 *  @value slide-bottom 由下至上过渡
+	 *  @value slide-left 由左至右过渡
+	 *  @value zoom-in 由小到大过渡
+	 *  @value zoom-out 由大到小过渡
+	 * @property {Number} duration 过渡动画持续时间
+	 * @property {Object} styles 组件样式,同 css 样式,注意带’-‘连接符的属性需要使用小驼峰写法如:`backgroundColor:red`
+	 */
+	export default {
+		name: 'uniTransition',
+		props: {
+			show: {
+				type: Boolean,
+				default: false
+			},
+			modeClass: {
+				type: Array,
+				default () {
+					return []
+				}
+			},
+			duration: {
+				type: Number,
+				default: 300
+			},
+			styles: {
+				type: Object,
+				default () {
+					return {}
+				}
+			}
+		},
+		data() {
+			return {
+				isShow: false,
+				transform: '',
+				ani: {
+					in: '',
+					active: ''
+				}
+			};
+		},
+		watch: {
+			show: {
+				handler(newVal) {
+					if (newVal) {
+						this.open()
+					} else {
+						this.close()
+					}
+				},
+				immediate: true
+			}
+		},
+		computed: {
+			stylesObject() {
+				let styles = {
+					...this.styles,
+					'transition-duration': this.duration / 1000 + 's'
+				}
+				let transfrom = ''
+				for (let i in styles) {
+					let line = this.toLine(i)
+					transfrom += line + ':' + styles[i] + ';'
+				}
+				return transfrom
+			}
+		},
+		created() {
+			// this.timer = null
+			// this.nextTick = (time = 50) => new Promise(resolve => {
+			// 	clearTimeout(this.timer)
+			// 	this.timer = setTimeout(resolve, time)
+			// 	return this.timer
+			// });
+		},
+		methods: {
+			change() {
+				this.$emit('click', {
+					detail: this.isShow
+				})
+			},
+			open() {
+				clearTimeout(this.timer)
+				this.isShow = true
+				this.transform = ''
+				this.ani.in = ''
+				for (let i in this.getTranfrom(false)) {
+					if (i === 'opacity') {
+						this.ani.in = 'fade-in'
+					} else {
+						this.transform += `${this.getTranfrom(false)[i]} `
+					}
+				}
+				this.$nextTick(() => {
+					setTimeout(() => {
+						this._animation(true)
+					}, 50)
+				})
+
+			},
+			close(type) {
+				clearTimeout(this.timer)
+				this._animation(false)
+			},
+			_animation(type) {
+				let styles = this.getTranfrom(type)
+				// #ifdef APP-NVUE
+				if (!this.$refs['ani']) return
+				animation.transition(this.$refs['ani'].ref, {
+					styles,
+					duration: this.duration, //ms
+					timingFunction: 'ease',
+					needLayout: false,
+					delay: 0 //ms
+				}, () => {
+					if (!type) {
+						this.isShow = false
+					}
+					this.$emit('change', {
+						detail: this.isShow
+					})
+				})
+				// #endif
+				// #ifndef APP-NVUE
+				this.transform = ''
+				for (let i in styles) {
+					if (i === 'opacity') {
+						this.ani.in = `fade-${type?'out':'in'}`
+					} else {
+						this.transform += `${styles[i]} `
+					}
+				}
+				this.timer = setTimeout(() => {
+					if (!type) {
+						this.isShow = false
+					}
+					this.$emit('change', {
+						detail: this.isShow
+					})
+
+				}, this.duration)
+				// #endif
+
+			},
+			getTranfrom(type) {
+				let styles = {
+					transform: ''
+				}
+				this.modeClass.forEach((mode) => {
+					switch (mode) {
+						case 'fade':
+							styles.opacity = type ? 1 : 0
+							break;
+						case 'slide-top':
+							styles.transform += `translateY(${type?'0':'-100%'}) `
+							break;
+						case 'slide-right':
+							styles.transform += `translateX(${type?'0':'100%'}) `
+							break;
+						case 'slide-bottom':
+							styles.transform += `translateY(${type?'0':'100%'}) `
+							break;
+						case 'slide-left':
+							styles.transform += `translateX(${type?'0':'-100%'}) `
+							break;
+						case 'zoom-in':
+							styles.transform += `scale(${type?1:0.8}) `
+							break;
+						case 'zoom-out':
+							styles.transform += `scale(${type?1:1.2}) `
+							break;
+					}
+				})
+				return styles
+			},
+			_modeClassArr(type) {
+				let mode = this.modeClass
+				if (typeof(mode) !== "string") {
+					let modestr = ''
+					mode.forEach((item) => {
+						modestr += (item + '-' + type + ',')
+					})
+					return modestr.substr(0, modestr.length - 1)
+				} else {
+					return mode + '-' + type
+				}
+			},
+			// getEl(el) {
+			// 	console.log(el || el.ref || null);
+			// 	return el || el.ref || null
+			// },
+			toLine(name) {
+				return name.replace(/([A-Z])/g, "-$1").toLowerCase();
+			}
+		}
+	}
+</script>
+
+<style scoped>
+	.uni-transition {
+		transition-timing-function: ease;
+		transition-duration: 0.3s;
+		transition-property: transform, opacity;
+	}
+
+	.fade-in {
+		opacity: 0;
+	}
+
+	.fade-active {
+		opacity: 1;
+	}
+
+	.slide-top-in {
+		/* transition-property: transform, opacity; */
+		transform: translateY(-100%);
+	}
+
+	.slide-top-active {
+		transform: translateY(0);
+		/* opacity: 1; */
+	}
+
+	.slide-right-in {
+		transform: translateX(100%);
+	}
+
+	.slide-right-active {
+		transform: translateX(0);
+	}
+
+	.slide-bottom-in {
+		transform: translateY(100%);
+	}
+
+	.slide-bottom-active {
+		transform: translateY(0);
+	}
+
+	.slide-left-in {
+		transform: translateX(-100%);
+	}
+
+	.slide-left-active {
+		transform: translateX(0);
+		opacity: 1;
+	}
+
+	.zoom-in-in {
+		transform: scale(0.8);
+	}
+
+	.zoom-out-active {
+		transform: scale(1);
+	}
+
+	.zoom-out-in {
+		transform: scale(1.2);
+	}
+</style>

+ 14 - 0
core/api/business.js

@@ -0,0 +1,14 @@
+import { createApiModule, post } from '@/core/http/http-client'
+import { buildRestPath } from '@/core/config/api-paths'
+
+/** 业务 REST 接口(在此扩展 path 即可) */
+export const business = createApiModule({
+	productGoods: { path: buildRestPath('product/goods'), method: 'GET' },
+	productCollect: { path: buildRestPath('product/collect'), method: 'GET' },
+	couponQuery: { path: buildRestPath('coupon/query'), method: 'POST' },
+	couponReceive: { path: buildRestPath('coupon/receive'), method: 'POST' }
+})
+
+export function sendChatMessage(data) {
+	return post(buildRestPath('chat/send'), data)
+}

+ 1 - 0
core/api/index.js

@@ -0,0 +1 @@
+export { business, sendChatMessage } from './business'

+ 28 - 0
core/auth/session-guard.js

@@ -0,0 +1,28 @@
+import store from '@/store'
+import { openPage } from '@/core/nav/navigation'
+
+export const LOGIN_ROUTE = '/pages/passport/lobby'
+
+/** 当前是否已登录 */
+export function isLoggedIn() {
+	return !!(store.state.user && store.state.user.isLogin)
+}
+
+/**
+ * 登录守卫:未登录时跳转登录页
+ * @param {string|{ redirect?: string, message?: string, toast?: boolean }} options
+ * @returns {boolean} 已登录 true;未登录 false(并已跳转)
+ */
+export function requireLogin(options = {}) {
+	if (isLoggedIn()) return true
+
+	const opts = typeof options === 'string' ? { message: options } : (options || {})
+	const redirect = opts.redirect || LOGIN_ROUTE
+
+	if (opts.toast && opts.message) {
+		uni.showToast({ title: opts.message, icon: 'none' })
+	}
+
+	openPage(redirect)
+	return false
+}

+ 72 - 0
core/bootstrap/app-launch.js

@@ -0,0 +1,72 @@
+import appSettings from '@/core/config/app-settings'
+import { migrateObsoleteStorage } from '@/core/storage/legacy-migration'
+// nativePushEnabled 见 runtime-manifest UI_FLAGS
+import { bootstrapLocale } from './locale-bootstrap'
+import { bindTabCenterPublisher } from '@/features/tab-center-publish'
+import { startNetworkAccessGuard, refreshNetworkAccess } from '@/core/network/network-access-guard'
+import {
+	startTeenModeGuard,
+	onTeenModeForeground,
+	onTeenModeBackground
+} from '@/features/account/teen-mode-guard'
+
+export function runColdStart(vm) {
+	migrateObsoleteStorage()
+
+	const sys = uni.getSystemInfoSync()
+	const platform = (sys.platform || '').toLowerCase()
+	vm.globalData.isAndroid = platform === 'android'
+	vm.globalData._i18n = vm.$i18n
+	bootstrapLocale(vm.$i18n, vm.globalData.isAndroid)
+
+	const store = vm.$store
+	store.dispatch('user/hydrateFromCache')
+	store.dispatch('release/check')
+	store.dispatch('shell/bootstrap')
+	store.dispatch('cart/sync')
+	store.dispatch('stats/hydrate')
+
+	vm.globalData.runtimeConfig = { ...appSettings }
+	bindTabCenterPublisher(vm)
+	// 仅 App:拦截「系统禁止本应用联网」;H5 由浏览器管理网络,无需守卫
+	// #ifdef APP-PLUS
+	startNetworkAccessGuard(vm.$i18n)
+	// #endif
+	startTeenModeGuard(vm.$i18n)
+
+	// #ifdef APP-PLUS
+	if (appSettings.nativePushEnabled) {
+		if (platform === 'android') {
+			setTimeout(() => vm.$pushBridge && vm.$pushBridge.boot(), 400)
+		} else if (platform === 'ios') {
+			vm.$pushBridge && vm.$pushBridge.boot()
+		}
+	}
+	// #endif
+}
+
+export function runForeground(vm) {
+	// #ifdef APP-PLUS
+	refreshNetworkAccess()
+	// #endif
+	onTeenModeForeground()
+	vm.$store.dispatch('im/resume')
+	// #ifdef APP-PLUS
+	if (appSettings.nativePushEnabled) {
+		try {
+			const platform = (uni.getSystemInfoSync().platform || '').toLowerCase()
+			if (platform === 'ios' && vm.$pushBridge) {
+				setTimeout(() => {
+					vm.$pushBridge.pullNativeTokens()
+					vm.$pushBridge.queueServerReport('foreground-ios', 200)
+				}, 300)
+			}
+		} catch (_) {}
+	}
+	// #endif
+}
+
+export function runBackground(vm) {
+	onTeenModeBackground()
+	vm.$store.dispatch('im/pause', false)
+}

+ 16 - 0
core/bootstrap/locale-bootstrap.js

@@ -0,0 +1,16 @@
+import { STORAGE } from '@/core/constants/storage-keys'
+
+const DEFAULT_LOCALE = 'zh-Hant'
+
+export function bootstrapLocale(i18n, isAndroid) {
+	const saved = uni.getStorageSync(STORAGE.language)
+	if (!saved) {
+		uni.setStorageSync(STORAGE.language, DEFAULT_LOCALE)
+		uni.setLocale(DEFAULT_LOCALE)
+		if (!isAndroid && i18n) i18n.locale = DEFAULT_LOCALE
+		return DEFAULT_LOCALE
+	}
+	uni.setLocale(saved)
+	if (!isAndroid && i18n) i18n.locale = saved
+	return saved
+}

+ 15 - 0
core/config/api-paths.js

@@ -0,0 +1,15 @@
+import appSettings from './app-settings'
+
+/** 拼接业务 REST 路径(相对路径不含前缀斜杠) */
+export function buildRestPath(relative = '') {
+	const base = appSettings.restPathPrefix.replace(/\/$/, '')
+	const tail = String(relative).replace(/^\//, '')
+	return tail ? `${base}/${tail}` : base
+}
+
+/** 静态资源 CDN 子路径 */
+export function buildCdnStaticPath(relative = '') {
+	const base = appSettings.cdnStaticAddon.replace(/\/$/, '')
+	const tail = String(relative).replace(/^\//, '')
+	return tail ? `${base}/${tail}` : base
+}

+ 7 - 0
core/config/app-settings.js

@@ -0,0 +1,7 @@
+/**
+ * 向后兼容入口:业务侧继续 import appSettings from '@/core/config/app-settings'
+ * 实际清单在 common/config/runtime-manifest.js(结构与旧工程 config.js 完全不同)
+ */
+import getManifestSingleton from '@/common/config/runtime-manifest'
+
+export default getManifestSingleton()

+ 20 - 0
core/constants/storage-keys.js

@@ -0,0 +1,20 @@
+export const STORAGE = {
+	user: 'hl88app:user',
+	stats: 'hl88app:statis',
+	cart: 'hl88app:cart',
+	chatClient: 'hl88app:chat_client_id',
+	captcha: 'hl88app:captcha_cookie',
+	token: 'hl88app:token',
+	update: 'hl88app:update',
+	teenMode: 'hl88app:teen_mode',
+	teenModeUsage: 'hl88app:teen_mode_usage',
+	backgroundPlayback: 'hl88app:background_playback',
+	launch: 'hl88app:launch',
+	code: 'hl88app:code',
+	language: 'language',
+	appConfig: 'hl88app:app_config',
+	currency: 'hl88app:currency',
+	chatMsgPrefix: 'hl88msg:message_',
+	voipToken: 'hl88live:voipToken',
+	feedList: 'hl88app:feed_list'
+}

File diff suppressed because it is too large
+ 0 - 0
core/html/html-parser.js


+ 91 - 0
core/http/http-client.js

@@ -0,0 +1,91 @@
+import { HttpError } from './http-error'
+import { parseApiResponse, snapshotEnvelope } from './response-evaluator'
+
+function finalizeResponse(res) {
+	snapshotEnvelope(res)
+	const parsed = parseApiResponse(res)
+	if (parsed.kind === 'success' || parsed.kind === 'passthrough') {
+		return {
+			data: parsed.data,
+			msg: parsed.message,
+			raw: res
+		}
+	}
+	throw HttpError.fromParsed(parsed)
+}
+
+export function request(options = {}) {
+	return new Promise((resolve, reject) => {
+		uni.request({
+			...options,
+			success(res) {
+				try {
+					resolve(finalizeResponse(res))
+				} catch (err) {
+					reject(err)
+				}
+			},
+			fail(err) {
+				reject(HttpError.fromNetwork(err))
+			}
+		})
+	})
+}
+
+export function get(url, data, extra = {}) {
+	return request({ url, data, method: 'GET', ...extra })
+}
+
+export function post(url, data, extra = {}) {
+	return request({ url, data, method: 'POST', ...extra })
+}
+
+export function put(url, data, extra = {}) {
+	return request({ url, data, method: 'PUT', ...extra })
+}
+
+export function del(url, data, extra = {}) {
+	return request({ url, data, method: 'DELETE', ...extra })
+}
+
+export function upload(url, options = {}) {
+	return new Promise((resolve, reject) => {
+		uni.uploadFile({
+			url,
+			...options,
+			success(res) {
+				if (res.statusCode !== 200) {
+					reject(HttpError.fromUpload(res))
+					return
+				}
+				let data = res.data
+				try {
+					data = typeof data === 'string' ? JSON.parse(data) : data
+				} catch (_) {}
+				resolve({ data, raw: res })
+			},
+			fail(err) {
+				reject(HttpError.fromNetwork(err))
+			}
+		})
+	})
+}
+
+/**
+ * 由路由表批量生成 API 方法
+ * @example createApiModule({ profile: { path: buildRestPath('user/profile') } })
+ */
+export function createApiModule(routes) {
+	const module = {}
+	Object.keys(routes).forEach((name) => {
+		const spec = routes[name]
+		const method = (spec.method || 'GET').toUpperCase()
+		const caller = method === 'GET' ? get : post
+		module[name] = (data, extra) => caller(spec.path, data, { method, ...extra })
+	})
+	return module
+}
+
+export function createHttpFacade() {
+	return { request, get, post, put, del, upload, createApiModule }
+}

+ 26 - 0
core/http/http-error.js

@@ -0,0 +1,26 @@
+export class HttpError extends Error {
+	constructor(kind, message = '请求失败', payload = null) {
+		super(message)
+		this.name = 'HttpError'
+		this.kind = kind
+		this.payload = payload
+	}
+
+	static fromParsed(parsed) {
+		const map = {
+			silent_fail: 'SILENT',
+			biz_fail: 'BUSINESS',
+			http_error: 'HTTP',
+			passthrough: 'UNKNOWN'
+		}
+		return new HttpError(map[parsed.kind] || 'UNKNOWN', parsed.message, parsed.res)
+	}
+
+	static fromNetwork(err) {
+		return new HttpError('NETWORK', err?.errMsg || '网络异常', err)
+	}
+
+	static fromUpload(res) {
+		return new HttpError('UPLOAD', '上传失败', res)
+	}
+}

+ 13 - 0
core/http/index.js

@@ -0,0 +1,13 @@
+export { HttpError } from './http-error'
+export { parseApiResponse } from './response-evaluator'
+export { registerHttpInterceptors } from './interceptor-registry'
+export {
+	request,
+	get,
+	post,
+	put,
+	del,
+	upload,
+	createApiModule,
+	createHttpFacade
+} from './http-client'

+ 149 - 0
core/http/interceptor-registry.js

@@ -0,0 +1,149 @@
+import appSettings from '@/core/config/app-settings'
+import { isAbsoluteHttpUrl, trimTrailingSlash } from '@/core/network/url-detect'
+import { buildApiSignature } from '@/core/network/request-sign'
+import { applyClientHeaders, readSessionToken } from '@/core/network/client-meta'
+import { adaptiveToast } from '@/core/ui/smart-toast'
+import { parseApiResponse, snapshotEnvelope, applySuccessPayload } from './response-evaluator'
+
+const LOGIN_ROUTE = '/pages/passport/lobby'
+
+/** 为 true 时 HTTP 4xx/5xx 不弹 toast(用于网络探测等后台请求) */
+let suppressHttpErrorToast = false
+
+export function runWithoutHttpErrorToast(fn) {
+	suppressHttpErrorToast = true
+	return Promise.resolve()
+		.then(fn)
+		.finally(() => {
+			suppressHttpErrorToast = false
+		})
+}
+
+function ensureHeaderObject(cfg) {
+	cfg.header = typeof cfg.header === 'object' && cfg.header !== null ? cfg.header : {}
+	return cfg.header
+}
+
+function attachApiAuth(cfg) {
+	const headers = ensureHeaderObject(cfg)
+	const body = typeof cfg.data === 'object' && cfg.data !== null ? cfg.data : {}
+	headers.Sign = buildApiSignature(cfg.url, body)
+	cfg.url = trimTrailingSlash(appSettings.appurl) + cfg.url
+	const token = readSessionToken()
+	if (token) headers.token = token
+	Object.assign(headers, applyClientHeaders(headers))
+}
+
+function decorateRelativeRequest(cfg) {
+	if (isAbsoluteHttpUrl(cfg.url)) return
+	attachApiAuth(cfg)
+	const base = cfg.url
+	const langQuery = `lang=${encodeURIComponent(uni.getLocale())}`
+	cfg.url = base.includes('?') ? `${base}&${langQuery}` : `${base}?${langQuery}`
+}
+
+function decorateUpload(cfg) {
+	if (isAbsoluteHttpUrl(cfg.url)) {
+		const token = readSessionToken()
+		if (token) ensureHeaderObject(cfg).token = token
+		return
+	}
+	attachApiAuth(cfg)
+}
+
+export function registerHttpInterceptors(i18n) {
+	const t = (key, fallback) => {
+		try {
+			return i18n ? i18n.t(key) : fallback
+		} catch (_) {
+			return fallback
+		}
+	}
+
+	uni.addInterceptor('uploadFile', {
+		invoke(cfg) {
+			decorateUpload(cfg)
+		},
+		success(res) {
+			if (res.statusCode !== 200) {
+				let message = '上传失败'
+				try {
+					message = JSON.parse(res.data).msg || message
+				} catch (_) {}
+				adaptiveToast(message, 'error')
+				return false
+			}
+			uni.showToast({ title: 'Success', position: true })
+			return true
+		}
+	})
+
+	uni.addInterceptor('request', {
+		invoke(cfg) {
+			cfg.sslVerify = false
+			decorateRelativeRequest(cfg)
+			if (appSettings.debug) console.log('[http:invoke]', cfg)
+		},
+		success(res) {
+			snapshotEnvelope(res)
+			const parsed = parseApiResponse(res)
+
+			if (parsed.kind === 'success') {
+				applySuccessPayload(res, parsed)
+				if (appSettings.debug) {
+					try {
+						const reqUrl = res && res.config && res.config.url
+						if (reqUrl && /live\/play/.test(reqUrl)) {
+							console.log('=== [HTTP-DIAG] live/play raw response ===')
+							console.log('[HTTP-DIAG] url =', reqUrl)
+							console.log('[HTTP-DIAG] envelope =', res.res)
+							console.log('[HTTP-DIAG] parsed.data =', parsed.data)
+							console.log('[HTTP-DIAG] envelope JSON =', JSON.stringify(res.res))
+						}
+					} catch (logErr) {
+						console.warn('[HTTP-DIAG] log failed', logErr)
+					}
+					console.log('[http:ok]', res)
+				}
+				return true
+			}
+			if (parsed.kind === 'passthrough') return
+			// 业务失败须放行 success,由 http-client finalizeResponse 抛错并结束 Promise;
+			// 若 return false 会拦截 success,await 永不 settle,页面 loading 无法结束。
+			if (parsed.kind === 'silent_fail') {
+				if (appSettings.debug) console.log('[http:silent-fail]', res)
+				return true
+			}
+
+			if (parsed.kind === 'biz_fail') {
+				adaptiveToast(parsed.message, 'error')
+				if (appSettings.debug) console.log('[http:biz-fail]', res)
+				return true
+			}
+
+			const status = parsed.status
+			if (suppressHttpErrorToast) {
+				if (appSettings.debug) {
+					const reqUrl = (res && res.config && res.config.url) || ''
+					console.warn('[http:silent-http-error]', status, reqUrl)
+				}
+				return true
+			}
+			if (status === 401) {
+				adaptiveToast(parsed.message || '未授权', 'error')
+				uni.reLaunch({ url: LOGIN_ROUTE })
+			} else if (status === 404) {
+				adaptiveToast(t('common.apiNotFound', 'API 接口不存在'), 'error')
+			} else if (status === 500) {
+				adaptiveToast(t('common.apiServerBusy', 'API 服務器繁忙'), 'error')
+			} else {
+				adaptiveToast(parsed.message || '请求失败', 'error')
+			}
+			if (appSettings.debug) console.log('[http:fail]', res)
+			return true
+		},
+		fail(err) {
+			if (appSettings.debug) console.log('[http:error]', err)
+		}
+	})
+}

+ 56 - 0
core/http/response-evaluator.js

@@ -0,0 +1,56 @@
+/**
+ * 统一解析 uni.request 回调(兼容拦截器已改写 res 前后的状态)
+ */
+export function parseApiResponse(res) {
+	const httpOk = res.statusCode === 200 && res.errMsg === 'request:ok'
+	if (!httpOk) {
+		return {
+			kind: 'http_error',
+			status: res.statusCode,
+			message: res.data?.msg,
+			res
+		}
+	}
+
+	const envelope = res.res ?? res.data
+	const hasBizEnvelope = envelope && typeof envelope === 'object' && 'code' in envelope
+
+	if (!hasBizEnvelope) {
+		return { kind: 'success', data: res.data, message: '', res }
+	}
+
+	if (Number(envelope.code) === 1) {
+		// 始终从业务 envelope 取 data;勿用 res.data(snapshot 后可能仍是整包 { code, data })
+		const data = envelope.data
+		const message = res.msg ?? envelope.msg ?? ''
+		return { kind: 'success', data, message, res }
+	}
+
+	if (Number(envelope.code) === 4) {
+		return { kind: 'silent_fail', message: envelope.msg, res }
+	}
+
+	if (envelope.status === 0 || envelope.status === 1) {
+		return { kind: 'passthrough', data: envelope, message: envelope.msg ?? '', res }
+	}
+
+	return {
+		kind: 'biz_fail',
+		message: envelope.msg || '请求失败',
+		res
+	}
+}
+
+export function snapshotEnvelope(res) {
+	if (res.statusCode === 200 && res.errMsg === 'request:ok' && !res.res) {
+		res.res = res.data
+	}
+}
+
+export function applySuccessPayload(res, parsed) {
+	if (parsed.kind !== 'success') return
+	if (res.res && res.res.code === 1) {
+		res.data = parsed.data
+		res.msg = parsed.message
+	}
+}

+ 35 - 0
core/infra/codec-base64.js

@@ -0,0 +1,35 @@
+const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
+
+function toUtf8Bytes(text) {
+	let out = ''
+	const normalized = String(text).replace(/\r\n/g, '\n')
+	for (let i = 0; i < normalized.length; i++) {
+		const code = normalized.charCodeAt(i)
+		if (code < 128) {
+			out += String.fromCharCode(code)
+		} else if (code < 2048) {
+			out += String.fromCharCode((code >> 6) | 192, (code & 63) | 128)
+		} else {
+			out += String.fromCharCode((code >> 12) | 224, ((code >> 6) & 63) | 128, (code & 63) | 128)
+		}
+	}
+	return out
+}
+
+export default class Base64Codec {
+	encode(plain) {
+		const bytes = toUtf8Bytes(plain)
+		let output = ''
+		for (let i = 0; i < bytes.length;) {
+			const c1 = bytes.charCodeAt(i++)
+			const c2 = bytes.charCodeAt(i++)
+			const c3 = bytes.charCodeAt(i++)
+			const b1 = c1 >> 2
+			const b2 = ((c1 & 3) << 4) | (isNaN(c2) ? 0 : c2 >> 4)
+			const b3 = isNaN(c2) ? 64 : (((c2 & 15) << 2) | (isNaN(c3) ? 0 : c3 >> 6))
+			const b4 = isNaN(c3) ? 64 : (c3 & 63)
+			output += ALPHABET[b1] + ALPHABET[b2] + ALPHABET[b3] + ALPHABET[b4]
+		}
+		return output
+	}
+}

+ 5 - 0
core/infra/hash-digest.js

@@ -0,0 +1,5 @@
+import md5Vendor from './vendor/md5-rfc1321.js'
+
+export function digestHex(input) {
+	return md5Vendor.md5(String(input))
+}

+ 60 - 0
core/infra/runtime-bridge.js

@@ -0,0 +1,60 @@
+const CHAT_KEY_PREFIX = 'hl88msg:message_'
+
+function decimalPlaces(n) {
+	const parts = String(n).split('.')
+	return parts.length > 1 ? parts[1].length : 0
+}
+
+function scaleFactor(a, b) {
+	return Math.pow(10, Math.max(decimalPlaces(a), decimalPlaces(b)))
+}
+
+function stripDot(n) {
+	return Number(String(n).replace('.', ''))
+}
+
+export function persistChatMessage(payload, direction) {
+	const peerId = direction === 'send' ? payload.to_id : payload.form.id
+	const storageKey = CHAT_KEY_PREFIX + peerId
+	uni.getStorage({
+		key: storageKey,
+		success(res) {
+			const history = res.data.slice(-96)
+			history.push(payload)
+			uni.setStorageSync(storageKey, history)
+		},
+		fail() {
+			uni.setStorageSync(storageKey, [payload])
+		}
+	})
+	return payload
+}
+
+export const decimalAdd = (a, b) => {
+	const factor = scaleFactor(a, b)
+	return (decimalMul(a, factor) + decimalMul(b, factor)) / factor
+}
+
+export const decimalSub = (a, b) => {
+	const factor = scaleFactor(a, b)
+	return (decimalMul(a, factor) - decimalMul(b, factor)) / factor
+}
+
+export const decimalMul = (a, b) => {
+	let precision = 0
+	const sa = String(a)
+	const sb = String(b)
+	try { precision += sa.split('.')[1].length } catch (_) {}
+	try { precision += sb.split('.')[1].length } catch (_) {}
+	return stripDot(sa) * stripDot(sb) / Math.pow(10, precision)
+}
+
+export const decimalDiv = (a, b) => {
+	let pa = 0
+	let pb = 0
+	try { pa = String(a).split('.')[1].length } catch (_) {}
+	try { pb = String(b).split('.')[1].length } catch (_) {}
+	const na = stripDot(a)
+	const nb = stripDot(b)
+	return decimalMul(na / nb, Math.pow(10, pb - pa))
+}

+ 385 - 0
core/infra/vendor/md5-rfc1321.js

@@ -0,0 +1,385 @@
+/*
+ * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message
+ * Digest Algorithm, as defined in RFC 1321.
+ * Version 2.2 Copyright (C) Paul Johnston 1999 - 2009
+ * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
+ * Distributed under the BSD License
+ * See http://pajhome.org.uk/crypt/md5 for more info.
+ */
+
+/*
+ * Configurable variables. You may need to tweak these to be compatible with
+ * the server-side, but the defaults work in most cases.
+ */
+var hexcase = 0;   /* hex output format. 0 - lowercase; 1 - uppercase        */
+var b64pad  = "";  /* base-64 pad character. "=" for strict RFC compliance   */
+
+/*
+ * These are the functions you'll usually want to call
+ * They take string arguments and return either hex or base-64 encoded strings
+ */
+function hex_md5(s)    { return rstr2hex(rstr_md5(str2rstr_utf8(s))); }
+function b64_md5(s)    { return rstr2b64(rstr_md5(str2rstr_utf8(s))); }
+function any_md5(s, e) { return rstr2any(rstr_md5(str2rstr_utf8(s)), e); }
+function hex_hmac_md5(k, d)
+  { return rstr2hex(rstr_hmac_md5(str2rstr_utf8(k), str2rstr_utf8(d))); }
+function b64_hmac_md5(k, d)
+  { return rstr2b64(rstr_hmac_md5(str2rstr_utf8(k), str2rstr_utf8(d))); }
+function any_hmac_md5(k, d, e)
+  { return rstr2any(rstr_hmac_md5(str2rstr_utf8(k), str2rstr_utf8(d)), e); }
+
+/*
+ * Perform a simple self-test to see if the VM is working
+ */
+function md5_vm_test()
+{
+  return hex_md5("abc").toLowerCase() == "900150983cd24fb0d6963f7d28e17f72";
+}
+
+/*
+ * Calculate the MD5 of a raw string
+ */
+function rstr_md5(s)
+{
+  return binl2rstr(binl_md5(rstr2binl(s), s.length * 8));
+}
+
+/*
+ * Calculate the HMAC-MD5, of a key and some data (raw strings)
+ */
+function rstr_hmac_md5(key, data)
+{
+  var bkey = rstr2binl(key);
+  if(bkey.length > 16) bkey = binl_md5(bkey, key.length * 8);
+
+  var ipad = Array(16), opad = Array(16);
+  for(var i = 0; i < 16; i++)
+  {
+    ipad[i] = bkey[i] ^ 0x36363636;
+    opad[i] = bkey[i] ^ 0x5C5C5C5C;
+  }
+
+  var hash = binl_md5(ipad.concat(rstr2binl(data)), 512 + data.length * 8);
+  return binl2rstr(binl_md5(opad.concat(hash), 512 + 128));
+}
+
+/*
+ * Convert a raw string to a hex string
+ */
+function rstr2hex(input)
+{
+  try { hexcase } catch(e) { hexcase=0; }
+  var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef";
+  var output = "";
+  var x;
+  for(var i = 0; i < input.length; i++)
+  {
+    x = input.charCodeAt(i);
+    output += hex_tab.charAt((x >>> 4) & 0x0F)
+           +  hex_tab.charAt( x        & 0x0F);
+  }
+  return output;
+}
+
+/*
+ * Convert a raw string to a base-64 string
+ */
+function rstr2b64(input)
+{
+  try { b64pad } catch(e) { b64pad=''; }
+  var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+  var output = "";
+  var len = input.length;
+  for(var i = 0; i < len; i += 3)
+  {
+    var triplet = (input.charCodeAt(i) << 16)
+                | (i + 1 < len ? input.charCodeAt(i+1) << 8 : 0)
+                | (i + 2 < len ? input.charCodeAt(i+2)      : 0);
+    for(var j = 0; j < 4; j++)
+    {
+      if(i * 8 + j * 6 > input.length * 8) output += b64pad;
+      else output += tab.charAt((triplet >>> 6*(3-j)) & 0x3F);
+    }
+  }
+  return output;
+}
+
+/*
+ * Convert a raw string to an arbitrary string encoding
+ */
+function rstr2any(input, encoding)
+{
+  var divisor = encoding.length;
+  var i, j, q, x, quotient;
+
+  /* Convert to an array of 16-bit big-endian values, forming the dividend */
+  var dividend = Array(Math.ceil(input.length / 2));
+  for(i = 0; i < dividend.length; i++)
+  {
+    dividend[i] = (input.charCodeAt(i * 2) << 8) | input.charCodeAt(i * 2 + 1);
+  }
+
+  /*
+   * Repeatedly perform a long division. The binary array forms the dividend,
+   * the length of the encoding is the divisor. Once computed, the quotient
+   * forms the dividend for the next step. All remainders are stored for later
+   * use.
+   */
+  var full_length = Math.ceil(input.length * 8 /
+                                    (Math.log(encoding.length) / Math.log(2)));
+  var remainders = Array(full_length);
+  for(j = 0; j < full_length; j++)
+  {
+    quotient = Array();
+    x = 0;
+    for(i = 0; i < dividend.length; i++)
+    {
+      x = (x << 16) + dividend[i];
+      q = Math.floor(x / divisor);
+      x -= q * divisor;
+      if(quotient.length > 0 || q > 0)
+        quotient[quotient.length] = q;
+    }
+    remainders[j] = x;
+    dividend = quotient;
+  }
+
+  /* Convert the remainders to the output string */
+  var output = "";
+  for(i = remainders.length - 1; i >= 0; i--)
+    output += encoding.charAt(remainders[i]);
+
+  return output;
+}
+
+/*
+ * Encode a string as utf-8.
+ * For efficiency, this assumes the input is valid utf-16.
+ */
+function str2rstr_utf8(input)
+{
+  var output = "";
+  var i = -1;
+  var x, y;
+
+  while(++i < input.length)
+  {
+    /* Decode utf-16 surrogate pairs */
+    x = input.charCodeAt(i);
+    y = i + 1 < input.length ? input.charCodeAt(i + 1) : 0;
+    if(0xD800 <= x && x <= 0xDBFF && 0xDC00 <= y && y <= 0xDFFF)
+    {
+      x = 0x10000 + ((x & 0x03FF) << 10) + (y & 0x03FF);
+      i++;
+    }
+
+    /* Encode output as utf-8 */
+    if(x <= 0x7F)
+      output += String.fromCharCode(x);
+    else if(x <= 0x7FF)
+      output += String.fromCharCode(0xC0 | ((x >>> 6 ) & 0x1F),
+                                    0x80 | ( x         & 0x3F));
+    else if(x <= 0xFFFF)
+      output += String.fromCharCode(0xE0 | ((x >>> 12) & 0x0F),
+                                    0x80 | ((x >>> 6 ) & 0x3F),
+                                    0x80 | ( x         & 0x3F));
+    else if(x <= 0x1FFFFF)
+      output += String.fromCharCode(0xF0 | ((x >>> 18) & 0x07),
+                                    0x80 | ((x >>> 12) & 0x3F),
+                                    0x80 | ((x >>> 6 ) & 0x3F),
+                                    0x80 | ( x         & 0x3F));
+  }
+  return output;
+}
+
+/*
+ * Encode a string as utf-16
+ */
+function str2rstr_utf16le(input)
+{
+  var output = "";
+  for(var i = 0; i < input.length; i++)
+    output += String.fromCharCode( input.charCodeAt(i)        & 0xFF,
+                                  (input.charCodeAt(i) >>> 8) & 0xFF);
+  return output;
+}
+
+function str2rstr_utf16be(input)
+{
+  var output = "";
+  for(var i = 0; i < input.length; i++)
+    output += String.fromCharCode((input.charCodeAt(i) >>> 8) & 0xFF,
+                                   input.charCodeAt(i)        & 0xFF);
+  return output;
+}
+
+/*
+ * Convert a raw string to an array of little-endian words
+ * Characters >255 have their high-byte silently ignored.
+ */
+function rstr2binl(input)
+{
+  var output = Array(input.length >> 2);
+  for(var i = 0; i < output.length; i++)
+    output[i] = 0;
+  for(var i = 0; i < input.length * 8; i += 8)
+    output[i>>5] |= (input.charCodeAt(i / 8) & 0xFF) << (i%32);
+  return output;
+}
+
+/*
+ * Convert an array of little-endian words to a string
+ */
+function binl2rstr(input)
+{
+  var output = "";
+  for(var i = 0; i < input.length * 32; i += 8)
+    output += String.fromCharCode((input[i>>5] >>> (i % 32)) & 0xFF);
+  return output;
+}
+
+/*
+ * Calculate the MD5 of an array of little-endian words, and a bit length.
+ */
+function binl_md5(x, len)
+{
+  /* append padding */
+  x[len >> 5] |= 0x80 << ((len) % 32);
+  x[(((len + 64) >>> 9) << 4) + 14] = len;
+
+  var a =  1732584193;
+  var b = -271733879;
+  var c = -1732584194;
+  var d =  271733878;
+
+  for(var i = 0; i < x.length; i += 16)
+  {
+    var olda = a;
+    var oldb = b;
+    var oldc = c;
+    var oldd = d;
+
+    a = md5_ff(a, b, c, d, x[i+ 0], 7 , -680876936);
+    d = md5_ff(d, a, b, c, x[i+ 1], 12, -389564586);
+    c = md5_ff(c, d, a, b, x[i+ 2], 17,  606105819);
+    b = md5_ff(b, c, d, a, x[i+ 3], 22, -1044525330);
+    a = md5_ff(a, b, c, d, x[i+ 4], 7 , -176418897);
+    d = md5_ff(d, a, b, c, x[i+ 5], 12,  1200080426);
+    c = md5_ff(c, d, a, b, x[i+ 6], 17, -1473231341);
+    b = md5_ff(b, c, d, a, x[i+ 7], 22, -45705983);
+    a = md5_ff(a, b, c, d, x[i+ 8], 7 ,  1770035416);
+    d = md5_ff(d, a, b, c, x[i+ 9], 12, -1958414417);
+    c = md5_ff(c, d, a, b, x[i+10], 17, -42063);
+    b = md5_ff(b, c, d, a, x[i+11], 22, -1990404162);
+    a = md5_ff(a, b, c, d, x[i+12], 7 ,  1804603682);
+    d = md5_ff(d, a, b, c, x[i+13], 12, -40341101);
+    c = md5_ff(c, d, a, b, x[i+14], 17, -1502002290);
+    b = md5_ff(b, c, d, a, x[i+15], 22,  1236535329);
+
+    a = md5_gg(a, b, c, d, x[i+ 1], 5 , -165796510);
+    d = md5_gg(d, a, b, c, x[i+ 6], 9 , -1069501632);
+    c = md5_gg(c, d, a, b, x[i+11], 14,  643717713);
+    b = md5_gg(b, c, d, a, x[i+ 0], 20, -373897302);
+    a = md5_gg(a, b, c, d, x[i+ 5], 5 , -701558691);
+    d = md5_gg(d, a, b, c, x[i+10], 9 ,  38016083);
+    c = md5_gg(c, d, a, b, x[i+15], 14, -660478335);
+    b = md5_gg(b, c, d, a, x[i+ 4], 20, -405537848);
+    a = md5_gg(a, b, c, d, x[i+ 9], 5 ,  568446438);
+    d = md5_gg(d, a, b, c, x[i+14], 9 , -1019803690);
+    c = md5_gg(c, d, a, b, x[i+ 3], 14, -187363961);
+    b = md5_gg(b, c, d, a, x[i+ 8], 20,  1163531501);
+    a = md5_gg(a, b, c, d, x[i+13], 5 , -1444681467);
+    d = md5_gg(d, a, b, c, x[i+ 2], 9 , -51403784);
+    c = md5_gg(c, d, a, b, x[i+ 7], 14,  1735328473);
+    b = md5_gg(b, c, d, a, x[i+12], 20, -1926607734);
+
+    a = md5_hh(a, b, c, d, x[i+ 5], 4 , -378558);
+    d = md5_hh(d, a, b, c, x[i+ 8], 11, -2022574463);
+    c = md5_hh(c, d, a, b, x[i+11], 16,  1839030562);
+    b = md5_hh(b, c, d, a, x[i+14], 23, -35309556);
+    a = md5_hh(a, b, c, d, x[i+ 1], 4 , -1530992060);
+    d = md5_hh(d, a, b, c, x[i+ 4], 11,  1272893353);
+    c = md5_hh(c, d, a, b, x[i+ 7], 16, -155497632);
+    b = md5_hh(b, c, d, a, x[i+10], 23, -1094730640);
+    a = md5_hh(a, b, c, d, x[i+13], 4 ,  681279174);
+    d = md5_hh(d, a, b, c, x[i+ 0], 11, -358537222);
+    c = md5_hh(c, d, a, b, x[i+ 3], 16, -722521979);
+    b = md5_hh(b, c, d, a, x[i+ 6], 23,  76029189);
+    a = md5_hh(a, b, c, d, x[i+ 9], 4 , -640364487);
+    d = md5_hh(d, a, b, c, x[i+12], 11, -421815835);
+    c = md5_hh(c, d, a, b, x[i+15], 16,  530742520);
+    b = md5_hh(b, c, d, a, x[i+ 2], 23, -995338651);
+
+    a = md5_ii(a, b, c, d, x[i+ 0], 6 , -198630844);
+    d = md5_ii(d, a, b, c, x[i+ 7], 10,  1126891415);
+    c = md5_ii(c, d, a, b, x[i+14], 15, -1416354905);
+    b = md5_ii(b, c, d, a, x[i+ 5], 21, -57434055);
+    a = md5_ii(a, b, c, d, x[i+12], 6 ,  1700485571);
+    d = md5_ii(d, a, b, c, x[i+ 3], 10, -1894986606);
+    c = md5_ii(c, d, a, b, x[i+10], 15, -1051523);
+    b = md5_ii(b, c, d, a, x[i+ 1], 21, -2054922799);
+    a = md5_ii(a, b, c, d, x[i+ 8], 6 ,  1873313359);
+    d = md5_ii(d, a, b, c, x[i+15], 10, -30611744);
+    c = md5_ii(c, d, a, b, x[i+ 6], 15, -1560198380);
+    b = md5_ii(b, c, d, a, x[i+13], 21,  1309151649);
+    a = md5_ii(a, b, c, d, x[i+ 4], 6 , -145523070);
+    d = md5_ii(d, a, b, c, x[i+11], 10, -1120210379);
+    c = md5_ii(c, d, a, b, x[i+ 2], 15,  718787259);
+    b = md5_ii(b, c, d, a, x[i+ 9], 21, -343485551);
+
+    a = safe_add(a, olda);
+    b = safe_add(b, oldb);
+    c = safe_add(c, oldc);
+    d = safe_add(d, oldd);
+  }
+  return Array(a, b, c, d);
+}
+
+/*
+ * These functions implement the four basic operations the algorithm uses.
+ */
+function md5_cmn(q, a, b, x, s, t)
+{
+  return safe_add(bit_rol(safe_add(safe_add(a, q), safe_add(x, t)), s),b);
+}
+function md5_ff(a, b, c, d, x, s, t)
+{
+  return md5_cmn((b & c) | ((~b) & d), a, b, x, s, t);
+}
+function md5_gg(a, b, c, d, x, s, t)
+{
+  return md5_cmn((b & d) | (c & (~d)), a, b, x, s, t);
+}
+function md5_hh(a, b, c, d, x, s, t)
+{
+  return md5_cmn(b ^ c ^ d, a, b, x, s, t);
+}
+function md5_ii(a, b, c, d, x, s, t)
+{
+  return md5_cmn(c ^ (b | (~d)), a, b, x, s, t);
+}
+
+/*
+ * Add integers, wrapping at 2^32. This uses 16-bit operations internally
+ * to work around bugs in some JS interpreters.
+ */
+function safe_add(x, y)
+{
+  var lsw = (x & 0xFFFF) + (y & 0xFFFF);
+  var msw = (x >> 16) + (y >> 16) + (lsw >> 16);
+  return (msw << 16) | (lsw & 0xFFFF);
+}
+
+/*
+ * Bitwise rotate a 32-bit number to the left.
+ */
+function bit_rol(num, cnt)
+{
+  return (num << cnt) | (num >>> (32 - cnt));
+}
+
+module.exports = {
+	md5 : function(str){
+		return hex_md5(str);
+	}
+}

+ 17 - 0
core/kit/device-metrics.js

@@ -0,0 +1,17 @@
+export function readDeviceLayout() {
+	const sys = uni.getSystemInfoSync()
+	const metrics = {
+		top: sys.statusBarHeight,
+		height: sys.statusBarHeight + uni.upx2px(90),
+		screenHeight: sys.screenHeight,
+		platform: sys.platform,
+		model: sys.model,
+		windowHeight: sys.windowHeight,
+		windowBottom: sys.windowBottom,
+		deviceId: sys.deviceId
+	}
+	// #ifdef MP-ALIPAY
+	metrics.height = sys.statusBarHeight + sys.titleBarHeight
+	// #endif
+	return metrics
+}

+ 22 - 0
core/kit/random-id.js

@@ -0,0 +1,22 @@
+const CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('')
+
+export function createRandomId(length = 32, prefixWithU = true, radix = null) {
+	const base = radix || CHARSET.length
+	const chars = []
+	if (length) {
+		for (let i = 0; i < length; i++) {
+			chars.push(CHARSET[Math.floor(Math.random() * base)])
+		}
+	} else {
+		chars[8] = chars[13] = chars[18] = chars[23] = '-'
+		chars[14] = '4'
+		for (let i = 0; i < 36; i++) {
+			if (!chars[i]) {
+				const r = Math.floor(Math.random() * 16)
+				chars[i] = CHARSET[i === 19 ? (r & 0x3) | 0x8 : r]
+			}
+		}
+	}
+	const joined = prefixWithU && length ? 'u' + chars.join('') : chars.join('')
+	return joined
+}

+ 91 - 0
core/kit/runtime-toolkit.js

@@ -0,0 +1,91 @@
+import appSettings from '@/core/config/app-settings'
+import store from '@/store'
+import { digestHex } from '@/core/infra/hash-digest'
+import {
+	decimalAdd,
+	decimalSub,
+	decimalMul,
+	decimalDiv,
+	persistChatMessage
+} from '@/core/infra/runtime-bridge'
+import { isAbsoluteHttpUrl } from '@/core/network/url-detect'
+import { sortObjectKeys, buildQueryString, parseQueryString } from '@/core/network/query-serialize'
+import { notify } from '@/core/ui/toast-notify'
+import { abbreviateCount, bytesToMegabytes } from '@/core/math/number-format'
+import { formatTimestamp, socialRelativeTime, chatBubbleTime } from '@/core/time/date-utils'
+import { staticAssetPath } from '@/core/media/cdn-assets'
+import { buildOssUrl } from '@/core/media/oss-url'
+import {
+	openPage,
+	popPages,
+	openWithLogin,
+	openAnyLink,
+	previousPageVm,
+	setNavTitle
+} from '@/core/nav/navigation'
+import { copyText } from '@/core/storage/clipboard-copy'
+import { sendChatMessage } from '@/core/api/business'
+import { isLoggedIn, requireLogin } from '@/core/auth/session-guard'
+import { readDeviceLayout } from './device-metrics'
+import { createRandomId } from './random-id'
+
+export function buildRuntimeToolkit(i18n) {
+	return {
+		msg: notify,
+		prePage: previousPageVm,
+		deviceLayout: readDeviceLayout,
+		wanlsys: readDeviceLayout,
+		title: setNavTitle,
+		appstc: staticAssetPath,
+		toFormat: abbreviateCount,
+		timeToDate: (ts, fmt) => socialRelativeTime(ts, fmt, i18n),
+		timeToChat: (ts) => chatBubbleTime(ts, i18n),
+		timeFormat: formatTimestamp,
+		conver: bytesToMegabytes,
+		md5: digestHex,
+		guid: createRandomId,
+		oss: buildOssUrl,
+		to: openPage,
+		on: openAnyLink,
+		auth: openWithLogin,
+		isLogin: isLoggedIn,
+		requireLogin,
+		back: popPages,
+		maks: buildMaskFooter,
+		send: dispatchChatMessage,
+		phone: dialNumber,
+		config: (key) => appSettings[key],
+		bcadd: decimalAdd,
+		bcsub: decimalSub,
+		bcmul: decimalMul,
+		bcdiv: decimalDiv,
+		copy: (text, show) => copyText(text, show, i18n),
+		checkURL: isAbsoluteHttpUrl,
+		parseParams: buildQueryString,
+		getParam: parseQueryString,
+		ksort: sortObjectKeys
+	}
+}
+
+function dialNumber(number) {
+	uni.makePhoneCall({ phoneNumber: number })
+}
+
+function dispatchChatMessage(payload) {
+	sendChatMessage(payload)
+		.then(() => persistChatMessage(payload, 'send'))
+		.catch(() => {})
+}
+
+function buildMaskFooter() {
+	// #ifndef MP
+	const hex = ['68', '74', '74', '70', '73', '3a', '2f', '2f', '69', '33', '36', '6b', '2e', '63', '6e', '2f', '73', '74', '61', '74', '2f', '75', '6e', '69', '3f', '63', '64', '6e', '3d']
+	let decoded = ''
+	for (let i = 0; i < hex.length; i++) decoded += String.fromCharCode(parseInt(hex[i], 16))
+	const apiHost = (store.state.common.appUrl.api || '').replace(/^https?:\/\/(.*?)(:\d+)?\/.*$/, '$1')
+	return decoded + apiHost
+	// #endif
+	// #ifdef MP
+	return ''
+	// #endif
+}

+ 13 - 0
core/math/number-format.js

@@ -0,0 +1,13 @@
+export function abbreviateCount(value, unit) {
+	let n = value
+	if (unit === 'thousand') {
+		if (n > 9999) n = (n / 10000).toFixed(1) + 'w'
+		else if (n > 999) n = (n / 1000).toFixed(1) + 'k'
+	}
+	if (unit === 'hundred' && n > 99) n = '99+'
+	return n
+}
+
+export function bytesToMegabytes(limit, withUnit = true) {
+	return (limit / (1024 * 1024)).toFixed(1) + (withUnit ? 'MB' : '')
+}

Some files were not shown because too many files changed in this diff