Files
medical-mall/pages/mall/admin/marketing/live/room.uvue
2026-02-15 16:37:37 +08:00

817 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="marketing-live-room">
<view class="filter-card border-shadow">
<view class="filter-row">
<view class="filter-item">
<text class="label">直播状态:</text>
<view class="select-mock">
<text class="select-val">全部</text>
<text class="arrow">▼</text>
</view>
</view>
<view class="filter-item ml-24">
<text class="label">搜索:</text>
<input class="input-mock" placeholder="请输入直播间名称/ID/主播昵称/微信号" />
</view>
<button class="btn-query ml-16">查询</button>
</view>
</view>
<view class="action-bar">
<button class="btn-add" @click="showDrawer = true">添加直播间</button>
<button class="btn-sync ml-16">同步直播间</button>
</view>
<view class="table-card border-shadow">
<view class="table-container">
<view class="table-head">
<view class="th cell-id">直播间ID</view>
<view class="th cell-name">直播间名称</view>
<view class="th cell-nick">主播昵称</view>
<view class="th cell-wechat">主播微信号</view>
<view class="th cell-time">直播开始时间</view>
<view class="th cell-time">计划结束时间</view>
<view class="th cell-time">创建时间</view>
<view class="th cell-status">显示状态</view>
<view class="th cell-live-status">直播状态</view>
<view class="th cell-sort">排序</view>
<view class="th cell-op">操作</view>
</view>
<view class="table-body">
<view v-for="item in roomList" :key="item.id" class="table-row">
<view class="td cell-id"><text class="td-txt">{{ item.id }}</text></view>
<view class="td cell-name"><text class="td-txt">{{ item.name }}</text></view>
<view class="td cell-nick"><text class="td-txt">{{ item.anchor_nick }}</text></view>
<view class="td cell-wechat"><text class="td-txt">{{ item.anchor_wechat }}</text></view>
<view class="td cell-time"><text class="td-txt-small">{{ item.start_time }}</text></view>
<view class="td cell-time"><text class="td-txt-small">{{ item.end_time }}</text></view>
<view class="td cell-time"><text class="td-txt-small">{{ item.create_time }}</text></view>
<view class="td cell-status">
<view class="switch-mock" :class="{ active: item.is_show }" @click="toggleStatus(item)">
<view class="switch-dot"></view>
<text class="switch-txt">{{ item.is_show ? '开启' : '关闭' }}</text>
</view>
</view>
<view class="td cell-live-status"><text class="td-txt">{{ item.live_status }}</text></view>
<view class="td cell-sort"><text class="td-txt">{{ item.sort }}</text></view>
<view class="td cell-op">
<view class="op-links">
<text class="op-link" @click="handleEdit(item)">详情</text>
<text class="op-split">|</text>
<text class="op-link" @click="handleDelete(item)">删除</text>
</view>
</view>
</view>
</view>
</view>
<view class="pagination-footer">
<view class="page-total"><text class="total-txt">共 {{ roomList.length }} 条</text></view>
<view class="page-select">
<view class="select-mock mini">
<text class="select-val">20条/页</text>
<text class="arrow">▼</text>
</view>
</view>
<view class="page-btns">
<text class="p-btn disabled"></text>
<text class="p-btn active">1</text>
<text class="p-btn disabled"></text>
</view>
</view>
</view>
<!-- Drawer Overlay -->
<view v-if="showDrawer || isAnimating" class="drawer-mask" :class="{ active: showDrawer }" @click="closeDrawer"></view>
<!-- Drawer Panel -->
<view class="drawer-panel" :class="{ active: showDrawer }">
<view class="drawer-header">
<view class="header-left">
<text class="back-btn" @click="closeDrawer"> 返回</text>
<text class="drawer-title">直播间管理</text>
</view>
</view>
<view class="drawer-content">
<view class="alert-info">
<text class="alert-txt">提示:必须前往微信小程序官方后台开通直播权限,关注【小程序直播】获知直播状态</text>
</view>
<view class="form-item">
<view class="form-label required">选择主播:</view>
<view class="select-mock full" @click="handleSelectAnchor">
<text class="select-val">{{ formData.anchor_nick || '请选择' }}</text>
<text class="arrow">▼</text>
</view>
</view>
<view class="form-item">
<view class="form-label required">直播间名称:</view>
<view class="input-wrap">
<input class="form-input" v-model="formData.name" placeholder="请输入直播间名称" />
<text class="char-count">{{ formData.name.length }}/80</text>
</view>
</view>
<view class="form-item">
<view class="form-label required">背景图:</view>
<view class="upload-box" @click="handleUpload('background')">
<view class="upload-placeholder" v-if="!formData.background">
<text class="up-ic">🖼️</text>
</view>
<image v-else :src="formData.background" class="upload-preview" mode="aspectFill" />
<text class="up-tip blue-bg">尺寸1080*1920px</text>
</view>
</view>
<view class="form-item">
<view class="form-label required">分享图:</view>
<view class="upload-box" @click="handleUpload('share')">
<view class="upload-placeholder" v-if="!formData.share_img">
<text class="up-ic">🖼️</text>
</view>
<image v-else :src="formData.share_img" class="upload-preview" mode="aspectFill" />
<text class="up-tip">尺寸800*640px</text>
</view>
</view>
<view class="form-item">
<view class="form-label">联系电话:</view>
<view class="input-wrap">
<input class="form-input" v-model="formData.phone" placeholder="请输入主播联系电话" />
<text class="char-count">{{ formData.phone.length }}/11</text>
</view>
</view>
<view class="form-item">
<view class="form-label required">直播时间:</view>
<view class="date-range-mock" @click="handleOpenDatePicker">
<text class="calendar-ic">📅</text>
<text class="date-val">{{ formData.start_time || '开始日期' }} - {{ formData.end_time || '结束日期' }}</text>
</view>
</view>
<view class="form-item">
<view class="form-label">排序:</view>
<input class="form-input w-extra-small" type="number" v-model="formData.sort" />
</view>
<view class="form-item">
<view class="form-label">直播间类型:</view>
<view class="radio-group">
<view class="radio-item" @click="formData.type = 'phone'">
<view class="radio-circle" :class="{ active: formData.type === 'phone' }"></view>
<text class="radio-txt">手机直播</text>
</view>
</view>
</view>
<view class="form-item flex-row">
<view class="form-label">直播间点赞:</view>
<view class="switch-mock" :class="{ active: formData.like_enabled }" @click="formData.like_enabled = !formData.like_enabled">
<view class="switch-dot"></view>
<text class="switch-txt">{{ formData.like_enabled ? '开启' : '关闭' }}</text>
</view>
</view>
<view class="form-item flex-row">
<view class="form-label">直播卖货:</view>
<view class="switch-mock" :class="{ active: formData.sale_enabled }" @click="formData.sale_enabled = !formData.sale_enabled">
<view class="switch-dot"></view>
<text class="switch-txt">{{ formData.sale_enabled ? '开启' : '关闭' }}</text>
</view>
</view>
<view class="form-item flex-row">
<view class="form-label">直播间评论:</view>
<view class="switch-mock" :class="{ active: formData.comment_enabled }" @click="formData.comment_enabled = !formData.comment_enabled">
<view class="switch-dot"></view>
<text class="switch-txt">{{ formData.comment_enabled ? '开启' : '关闭' }}</text>
</view>
</view>
<view class="form-actions-bottom">
<button class="btn-submit" @click="handleSubmit">提交</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import {
fetchLiveRooms,
saveLiveRoom,
deleteLiveRoom,
fetchLiveAnchors,
LiveRoom,
LiveAnchor
} from '@/services/admin/marketingService.uts'
const showDrawer = ref(false)
const isAnimating = ref(false)
const isLoading = ref(false)
const isSaving = ref(false)
const roomList = ref<LiveRoom[]>([])
const anchorList = ref<LiveAnchor[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const searchQuery = ref('')
const formData = ref<LiveRoom>({
name: '',
anchor_id: null,
anchor_nick: '',
background_url: '',
share_img_url: '',
start_time: '',
end_time: '',
sort: 0,
type: 'phone',
like_enabled: true,
sale_enabled: true,
comment_enabled: true,
is_show: true,
live_status: 1
})
onMounted(() => {
loadData()
loadAnchors()
})
async function loadData() {
isLoading.value = true
try {
const res = await fetchLiveRooms({
search: searchQuery.value,
page: page.value,
pageSize: pageSize
})
roomList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载直播间失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
async function loadAnchors() {
anchorList.value = await fetchLiveAnchors()
}
async function toggleStatus(item : LiveRoom) {
if (item.id == null) return
const nextStatus = !item.is_show
const success = await saveLiveRoom({ ...item, is_show: nextStatus } as LiveRoom)
if (success) {
item.is_show = nextStatus
uni.showToast({ title: '状态修改成功', icon: 'success' })
}
}
function handleAdd() {
formData.value = {
name: '',
anchor_id: null,
anchor_nick: '',
background_url: '',
share_img_url: '',
start_time: '',
end_time: '',
sort: 0,
type: 'phone',
like_enabled: true,
sale_enabled: true,
comment_enabled: true,
is_show: true,
live_status: 1
} as LiveRoom
showDrawer.value = true
}
function handleEdit(item : LiveRoom) {
formData.value = { ...item } as LiveRoom
showDrawer.value = true
}
async function handleDelete(item : LiveRoom) {
if (item.id == null) return
uni.showModal({
title: '提示',
content: '确定要删除该直播间吗?',
success: async (res) => {
if (res.confirm) {
const success = await deleteLiveRoom(item.id!)
if (success) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
function handleSearch() {
page.value = 1
loadData()
}
async function handleSubmit() {
if (!formData.value.name || !formData.value.start_time) {
uni.showToast({ title: '请填写必填项', icon: 'none' })
return
}
isSaving.value = true
try {
const success = await saveLiveRoom(formData.value)
if (success) {
uni.showToast({ title: '提交成功', icon: 'success' })
closeDrawer()
loadData()
}
} catch (e) {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
isSaving.value = false
}
}
function handleSelectAnchor() {
const nicks = anchorList.value.map(a => a.nickname)
if (nicks.length === 0) {
uni.showToast({ title: '请先添加主播', icon: 'none' })
return
}
uni.showActionSheet({
itemList: nicks,
success: (res) => {
const anchor = anchorList.value[res.tapIndex]
formData.value.anchor_id = anchor.id ?? null
formData.value.anchor_nick = anchor.nickname
}
})
}
function handleUpload(field: string) {
uni.chooseImage({
count: 1,
success: (res) => {
if (field === 'background') {
formData.value.background_url = res.tempFilePaths[0]
} else {
formData.value.share_img_url = res.tempFilePaths[0]
}
}
})
}
function handleOpenDatePicker() {
// 简化处理,实际推荐使用日期选择组件
uni.showToast({ title: '请在输入框直接修改时间', icon: 'none' })
}
const closeDrawer = () => {
showDrawer.value = false
isAnimating.value = true
setTimeout(() => {
isAnimating.value = false
}, 300)
}
function onPrevPage() {
if (page.value > 1) {
page.value--
loadData()
}
}
function onNextPage() {
if (roomList.value.length >= pageSize) {
page.value++
loadData()
}
}
</script>
<style scoped lang="scss">
.marketing-live-room {
min-height: 100vh;
background: #f0f2f5;
padding: 16px;
}
.border-shadow {
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.ml-16 { margin-left: 16px; }
.ml-24 { margin-left: 24px; }
.mt-16 { margin-top: 16px; }
/* 过滤栏 */
.filter-card {
padding: 24px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
flex-direction: row;
align-items: center;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
}
.label {
font-size: 14px;
color: #606266;
white-space: nowrap;
}
.input-mock, .select-mock {
width: 200px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 12px;
font-size: 13px;
}
.input-mock { width: 300px; }
.select-mock { width: 160px; justify-content: space-between; }
.select-mock.mini { width: 100px; height: 28px; }
.select-mock.full { width: 100%; }
.select-val { font-size: 13px; color: #606266; }
.arrow { font-size: 10px; color: #c0c4cc; }
.btn-query {
width: 64px;
height: 32px;
background-color: #1890ff;
color: #fff;
font-size: 14px;
border: none;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
/* 操作栏 */
.action-bar {
margin-bottom: 16px;
display: flex;
flex-direction: row;
}
.btn-add {
width: auto;
padding: 0 16px;
height: 32px;
background-color: #1890ff;
color: #fff;
font-size: 14px;
border: none;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.btn-sync {
width: auto;
padding: 0 16px;
height: 32px;
background-color: #fff;
color: #1890ff;
border: 1px solid #1890ff;
font-size: 14px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
/* 表格区域 */
.table-card {
padding: 24px;
}
.table-head {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
border-bottom: 1px solid #e8eaec;
}
.th {
padding: 12px 8px;
font-size: 13px;
color: #515a6e;
font-weight: bold;
}
.table-row {
display: flex;
flex-direction: row;
border-bottom: 1px solid #e8eaec;
align-items: center;
}
.td {
padding: 16px 8px;
}
.td-txt { font-size: 13px; color: #515a6e; }
.td-txt-small { font-size: 12px; color: #808695; display: block; }
/* 各列宽度 */
.cell-id { width: 70px; }
.cell-name { flex: 1; min-width: 150px; }
.cell-nick { width: 120px; }
.cell-wechat { width: 120px; }
.cell-time { width: 150px; }
.cell-status { width: 100px; text-align: center; }
.cell-live-status { width: 100px; text-align: center; }
.cell-sort { width: 60px; text-align: center; }
.cell-op { width: 120px; text-align: right; }
.switch-mock {
width: 50px;
height: 24px;
background-color: #bfbfbf;
border-radius: 12px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 4px;
position: relative;
transition: background-color 0.3s;
}
.switch-mock.active { background-color: #1890ff; }
.switch-dot {
width: 16px;
height: 16px;
background-color: #fff;
border-radius: 50%;
position: absolute;
left: 4px;
transition: left 0.3s;
}
.switch-mock.active .switch-dot { left: 30px; }
.switch-txt { font-size: 11px; color: #fff; margin-left: 20px; }
.switch-mock.active .switch-txt { margin-left: 4px; }
.op-links {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
.op-link { color: #1890ff; font-size: 13px; cursor: pointer; }
.op-split { color: #e8eaec; margin: 0 8px; }
/* 分页 */
.pagination-footer {
margin-top: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.total-txt { font-size: 13px; color: #606266; }
/* Drawer Styles */
.drawer-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.45);
z-index: 1000;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.drawer-mask.active {
opacity: 1;
pointer-events: auto;
}
.drawer-panel {
position: fixed;
top: 0;
right: -50%;
width: 50%;
height: 100%;
background-color: #fff;
z-index: 1001;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
transition: right 0.3s ease-out;
}
.drawer-panel.active {
right: 0;
}
.drawer-header {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
}
.header-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
}
.back-btn {
font-size: 14px;
color: #8c8c8c;
cursor: pointer;
}
.drawer-title {
font-size: 16px;
font-weight: 600;
color: #262626;
}
.drawer-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.alert-info {
background-color: #fff7e6;
border: 1px solid #ffe7ba;
padding: 12px 16px;
margin-bottom: 24px;
border-radius: 4px;
}
.alert-txt {
font-size: 13px;
color: #fa8c16;
}
.form-item {
margin-bottom: 24px;
}
.flex-row {
display: flex;
flex-direction: row;
align-items: center;
}
.form-label {
display: block;
font-size: 14px;
color: #262626;
margin-bottom: 8px;
width: 120px;
}
.flex-row .form-label { margin-bottom: 0; }
.required::before {
content: '*';
color: #ff4d4f;
margin-right: 4px;
}
.input-wrap {
position: relative;
width: 100%;
}
.form-input {
width: 100%;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 0 40px 0 12px;
font-size: 14px;
}
.w-extra-small { width: 80px; }
.char-count {
position: absolute;
right: 12px;
top: 6px;
font-size: 12px;
color: #bfbfbf;
}
.upload-box {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 12px;
}
.upload-placeholder {
width: 80px;
height: 80px;
border: 1px dashed #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
background-color: #fafafa;
}
.up-ic { font-size: 24px; color: #bfbfbf; }
.up-tip {
font-size: 12px;
color: #1890ff;
background-color: #e6f7ff;
border: 1px solid #91d5ff;
padding: 2px 8px;
border-radius: 2px;
}
.up-tip.blue-bg {
background-color: #1890ff;
color: #fff;
border: none;
}
.date-range-mock {
width: 100%;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 12px;
gap: 8px;
}
.calendar-ic { font-size: 14px; color: #bfbfbf; }
.date-val { font-size: 14px; color: #bfbfbf; }
.radio-group {
display: flex;
flex-direction: row;
gap: 24px;
}
.radio-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.radio-circle {
width: 16px;
height: 16px;
border: 1px solid #d9d9d9;
border-radius: 50%;
position: relative;
}
.radio-circle.active { border-color: #1890ff; }
.radio-circle.active::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 8px;
height: 8px;
background-color: #1890ff;
border-radius: 50%;
}
.radio-txt { font-size: 14px; color: #262626; }
.form-actions-bottom {
margin-top: 40px;
}
.btn-submit {
width: 64px;
height: 32px;
background-color: #1890ff;
color: #fff;
font-size: 14px;
border: none;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
</style>