Files
chatroom/resources/views/chat/partials/user-actions.blade.php
lkddi 5f30220609 feat: 任命/撤销通知系统 + 用户名片UI优化
- 任命/撤销事件增加 type 字段区分类型
- 任命:全屏礼花 + 紫色弹窗 + 紫色系统消息
- 撤销:灰色弹窗 + 灰色系统消息,无礼花
- 消息分发:操作者/被操作者显示在私聊面板,其他人显示在公屏
- 系统消息加随机鼓励语(各5条轮换)
- ChatStateService 修复 Redis key 前缀扫描问题(getAllActiveRoomIds)
- 用户名片折叠优化:管理员视野、职务履历均可折叠
- 管理操作 + 职务操作合并为「🔧 管理操作」折叠区
- 悄悄话改为「🎁 送礼物」按钮,礼物面板内联展开
2026-02-28 23:44:38 +08:00

733 lines
39 KiB
PHP
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.
{{--
文件功能:用户交互全局函数 + 名片弹窗组件
包含:
1. switchTarget() 单击用户名切换聊天目标
2. openUserCard() 双击用户名打开名片弹窗
3. 用户名片弹窗 Alpine.js 组件(资料查看 + 送花 + 管理操作)
scripts.blade.php frame.blade.php 中抽取,保持代码职责清晰。
@author ChatRoom Laravel
@version 1.0.0
--}}
{{-- ═══════════ 全局交互函数 ═══════════ --}}
<script>
/**
* 全局函数:单击用户名 → 切换聊天目标
*
* 将「对...说」下拉选中为该用户,方便直接发悄悄话。
* 若用户已离线不在下拉列表中,临时添加一个 option。
*/
function switchTarget(username) {
const toUserSelect = document.getElementById('to_user');
if (!toUserSelect) return;
const options = toUserSelect.options;
let found = false;
for (let i = 0; i < options.length; i++) {
if (options[i].value === username) {
toUserSelect.value = username;
found = true;
break;
}
}
// 如果不在列表中(可能已离线),临时添加
if (!found && username !== '大家') {
const opt = document.createElement('option');
opt.value = username;
opt.textContent = username;
toUserSelect.appendChild(opt);
toUserSelect.value = username;
}
// 切换目标后自动聚焦输入框,方便直接输入
document.getElementById('content').focus();
}
/**
* 全局函数:双击用户名 → 打开名片弹窗
*
* 聊天消息区和右侧用户列表统一调用此函数。
* 通过 Alpine.js 的 fetchUser 方法加载用户资料并显示弹窗。
*/
function openUserCard(username) {
const el = document.getElementById('user-modal-container');
if (el) {
const data = Alpine.$data(el);
if (data) data.fetchUser(username);
}
}
</script>
{{-- ═══════════ 用户名片弹窗 (Alpine.js) ═══════════ --}}
@php $gifts = \App\Models\Gift::activeList(); @endphp
<script>
/**
* 礼物数据注入(避免 JSON 破坏 x-data 属性解析)
*/
window.__gifts = {!! Js::from($gifts) !!};
window.__defaultGiftId = {{ $gifts->first()?->id ?? 0 }};
/**
* 用户名片弹窗 Alpine.js 组件定义
* 提取到 script 标签避免 HTML 属性中的引号冲突
*/
function userCardComponent() {
return {
showUserModal: false,
userInfo: {},
isMuting: false,
muteDuration: 5,
showWhispers: false,
whisperList: [],
showAnnounce: false,
announceText: '',
gifts: window.__gifts || [],
selectedGiftId: window.__defaultGiftId || 0,
giftCount: 1,
sendingGift: false,
// 任命相关
showAppointPanel: false,
appointPositions: [],
selectedPositionId: null,
appointRemark: '',
appointLoading: false,
// 折叠状态
showAdminView: false, // 管理员视野
showPositionHistory: false, // 职务履历
showAdminPanel: false, // 管理操作(管理操作+职务操作合并)
/** 获取用户资料 */
async fetchUser(username) {
try {
const res = await fetch('/user/' + encodeURIComponent(username), {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
console.error('Failed to fetch user:', errorData.message || res.statusText);
return;
}
const data = await res.json();
if (data.status === 'success') {
this.userInfo = data.data;
this.showUserModal = true;
this.isMuting = false;
this.showWhispers = false;
this.whisperList = [];
this.showAppointPanel = false;
this.selectedPositionId = null;
this.appointRemark = '';
// 有职务的操作人预加载可用职务列表
if (window.chatContext?.hasPosition) {
this._loadPositions();
}
}
} catch (e) {
console.error('Error fetching user:', e);
}
},
/** 加载可任命职务列表 */
async _loadPositions() {
if (!window.chatContext?.appointPositionsUrl) return;
try {
const res = await fetch(window.chatContext.appointPositionsUrl, {
headers: {
'Accept': 'application/json'
}
});
const data = await res.json();
if (data.status === 'success') {
this.appointPositions = data.positions;
if (this.appointPositions.length > 0) {
this.selectedPositionId = this.appointPositions[0].id;
}
}
} catch (e) {
/* 静默失败 */
}
},
/** 快速任命 */
async doAppoint() {
if (this.appointLoading || !this.selectedPositionId) return;
this.appointLoading = true;
try {
const res = await fetch(window.chatContext.appointUrl, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
position_id: this.selectedPositionId,
remark: this.appointRemark.trim() || null,
room_id: window.chatContext.roomId ?? null,
})
});
const data = await res.json();
alert(data.message);
if (data.status === 'success') {
this.showUserModal = false;
}
} catch (e) {
alert('网络异常');
}
this.appointLoading = false;
},
/** 快速撤销 */
async doRevoke() {
if (!confirm('确定要撤销 ' + this.userInfo.username + ' 的职务吗?')) return;
this.appointLoading = true;
try {
const res = await fetch(window.chatContext.revokeUrl, {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
remark: '聊天室快速撤销',
room_id: window.chatContext.roomId ?? null,
})
});
const data = await res.json();
alert(data.message);
if (data.status === 'success') {
this.showUserModal = false;
}
} catch (e) {
alert('网络异常');
}
this.appointLoading = false;
},
/** 踢出用户 */
async kickUser() {
const reason = prompt('踢出原因(可留空):', '违反聊天室规则');
if (reason === null) return;
try {
const res = await fetch('/command/kick', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
reason: reason || '违反聊天室规则'
})
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
}
} catch (e) {
alert('网络异常');
}
},
/** 禁言用户 */
async muteUser() {
try {
const res = await fetch('/command/mute', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
duration: this.muteDuration
})
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
}
} catch (e) {
alert('网络异常');
}
},
/** 警告用户 */
async warnUser() {
const reason = prompt('警告原因:', '请注意言行');
if (reason === null) return;
try {
const res = await fetch('/command/warn', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
reason: reason || '请注意言行'
})
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
}
} catch (e) {
alert('网络异常');
}
},
/** 冻结用户 */
async freezeUser() {
if (!confirm('确定要冻结 ' + this.userInfo.username + ' 的账号吗?冻结后将无法登录!')) return;
const reason = prompt('冻结原因:', '严重违规');
if (reason === null) return;
try {
const res = await fetch('/command/freeze', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
username: this.userInfo.username,
room_id: window.chatContext.roomId,
reason: reason || '严重违规'
})
});
const data = await res.json();
if (data.status === 'success') {
this.showUserModal = false;
} else {
alert('操作失败:' + data.message);
}
} catch (e) {
alert('网络异常');
}
},
/** 查看私信记录 */
async loadWhispers() {
try {
const res = await fetch('/command/whispers/' + encodeURIComponent(this.userInfo.username), {
headers: {
'Accept': 'application/json'
}
});
const data = await res.json();
if (data.status === 'success') {
this.whisperList = data.messages;
this.showWhispers = true;
} else {
alert(data.message);
}
} catch (e) {
alert('网络异常');
}
},
/** 发送全服公告 */
async sendAnnounce() {
if (!this.announceText.trim()) return;
try {
const res = await fetch('/command/announce', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
content: this.announceText,
room_id: window.chatContext.roomId,
})
});
const data = await res.json();
if (data.status === 'success') {
this.announceText = '';
this.showAnnounce = false;
} else {
alert(data.message);
}
} catch (e) {
alert('网络异常');
}
},
/** 送礼物 */
async sendGift() {
if (this.sendingGift || !this.selectedGiftId) return;
this.sendingGift = true;
try {
const res = await fetch('/gift/flower', {
method: 'POST',
headers: this._headers(),
body: JSON.stringify({
to_user: this.userInfo.username,
room_id: window.chatContext.roomId,
gift_id: this.selectedGiftId,
count: this.giftCount
})
});
const data = await res.json();
alert(data.message);
if (data.status === 'success') {
this.showUserModal = false;
this.giftCount = 1;
}
} catch (e) {
alert('网络异常');
}
this.sendingGift = false;
},
/** 通用请求头 */
_headers() {
return {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json',
'Accept': 'application/json'
};
}
};
}
</script>
<div id="user-modal-container" x-data="userCardComponent()">
<div x-show="showUserModal" style="display: none;" class="modal-overlay" x-on:click.self="showUserModal = false">
<div class="modal-card" x-transition>
{{-- 弹窗头部 --}}
<div class="modal-header">
<h3 x-text="'用户名片 · ' + userInfo.username"></h3>
<button class="modal-close" x-on:click="showUserModal = false">&times;</button>
</div>
{{-- 弹窗内容 --}}
<div class="modal-body">
<div class="profile-row">
<img class="profile-avatar" x-show="userInfo.headface"
:src="'/images/headface/' + (userInfo.headface || '1.gif').toLowerCase()"
x-on:error="$el.style.display='none'">
<div class="profile-info">
<h4>
<span x-text="userInfo.username"></span>
<span class="level-badge" x-text="'LV.' + userInfo.user_level"></span>
<span class="sex-badge"
x-text="userInfo.sex === '男' ? '♂' : (userInfo.sex === '女' ? '♀' : '')"
:style="userInfo.sex === '男' ? 'color: blue' : (userInfo.sex === '女' ?
'color: deeppink' : '')"></span>
</h4>
{{-- 在职职务标签(有职务时才显示) --}}
<div x-show="userInfo.position_name"
style="display: inline-flex; align-items: center; gap: 3px; margin-top: 3px; padding: 2px 8px; background: #f3e8ff; border: 1px solid #d8b4fe; border-radius: 20px; font-size: 11px; color: #7c3aed; font-weight: bold;">
<span x-text="userInfo.position_icon" style="font-size: 13px;"></span>
<span
x-text="(userInfo.department_name ? userInfo.department_name + ' · ' : '') + userInfo.position_name"></span>
</div>
<div style="font-size: 11px; color: #999; margin-top: 4px;">
加入: <span x-text="userInfo.created_at"></span>
</div>
</div>
</div>
{{-- 详细信息区:外层 x-show 控制显隐,内层单独写 flex 避免被 Alpine 覆盖 --}}
<div x-show="userInfo.exp_num !== undefined" style="margin-top: 12px;">
<div style="display: flex; flex-direction: row; gap: 8px;">
<!-- 经验 -->
<div
style="flex: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #64748b; font-size: 11px; margin-bottom: 2px;">经验</span>
<span x-text="userInfo.exp_num"
style="font-weight: 700; color: #4f46e5; font-size: 14px;"></span>
</div>
<!-- 金币 -->
<div
style="flex: 1; background: #fdfae8; border: 1px solid #fef08a; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #b45309; font-size: 11px; margin-bottom: 2px;">金币</span>
<span x-text="userInfo.jjb"
style="font-weight: 700; color: #d97706; font-size: 14px;"></span>
</div>
<!-- 魅力 -->
<div
style="flex: 1; background: #fdf2f8; border: 1px solid #fbcfe8; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #be185d; font-size: 11px; margin-bottom: 2px;">魅力</span>
<span x-text="userInfo.meili"
style="font-weight: 700; color: #db2777; font-size: 14px;"></span>
</div>
</div>
</div>
{{-- 管理员可见区域 (IP 归属地) --}}
<div x-show="userInfo.last_ip !== undefined"
style="margin-top: 8px; border-radius: 8px; overflow: hidden;">
{{-- 可点击标题 --}}
<div x-on:click="showAdminView = !showAdminView"
style="display: flex; align-items: center; justify-content: space-between; padding: 6px 10px;
background: #fee2e2; border: 1px dashed #fca5a5; border-radius: 8px;
cursor: pointer; font-size: 11px; color: #991b1b; font-weight: bold; user-select: none;">
<span>🛡️ 管理员视野</span>
<span x-text="showAdminView ? '▲' : '▼'" style="font-size: 10px; opacity: 0.6;"></span>
</div>
{{-- 折叠内容 --}}
<div x-show="showAdminView" x-transition
style="display: none; padding: 8px 10px; background: #fff5f5; border: 1px dashed #fca5a5;
border-top: none; border-radius: 0 0 8px 8px; font-size: 11px; color: #991b1b;">
<div style="display: flex; flex-direction: column; gap: 3px;">
<div><span style="opacity: 0.8;">主要IP</span><span x-text="userInfo.last_ip || '无'"></span>
</div>
<div><span style="opacity: 0.8;">本次IP</span><span x-text="userInfo.login_ip || '无'"></span>
</div>
<div><span style="opacity: 0.8;">归属地:</span><span x-text="userInfo.location || '未知'"></span>
</div>
</div>
</div>
</div>
<div class="profile-detail" x-text="userInfo.sign || '这家伙很懒,什么也没留下'" style="margin-top: 12px;"></div>
{{-- 职务履历时间轴(有任职记录才显示,可折叠) --}}
<div x-show="userInfo.position_history && userInfo.position_history.length > 0"
style="display: none; margin-top: 10px; border-top: 1px solid #f0f0f0; padding-top: 8px;">
{{-- 可点击标题 --}}
<div x-on:click="showPositionHistory = !showPositionHistory"
style="display: flex; align-items: center; justify-content: space-between;
cursor: pointer; font-size: 11px; font-weight: bold; color: #7c3aed;
margin-bottom: 4px; user-select: none;">
<span>🎖️ 职务履历 <span style="font-weight: normal; font-size: 10px; color: #9ca3af;"
x-text="'' + userInfo.position_history.length + ' 条)'"></span></span>
<span x-text="showPositionHistory ? '▲' : '▼'" style="font-size: 10px; opacity:0.5;"></span>
</div>
{{-- 折叠内容 --}}
<div x-show="showPositionHistory" x-transition style="display: none;">
<template x-for="(h, idx) in userInfo.position_history" :key="idx">
<div style="display: flex; gap: 10px; margin-bottom: 8px; position: relative;">
{{-- 线 --}}
<div
style="display: flex; flex-direction: column; align-items: center; width: 18px; flex-shrink: 0;">
<div style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 2px;"
:style="h.is_active ? 'background: #7c3aed; box-shadow: 0 0 0 3px #ede9fe;' :
'background: #d1d5db;'">
</div>
<template x-if="idx < userInfo.position_history.length - 1">
<div style="width: 1px; flex: 1; background: #e5e7eb; margin-top: 2px;"></div>
</template>
</div>
{{-- 内容 --}}
<div style="flex: 1; font-size: 11px; padding-bottom: 4px;">
<div style="font-weight: bold; color: #374151;">
<span x-text="h.position_icon" style="margin-right: 2px;"></span>
<span
x-text="(h.department_name ? h.department_name + ' · ' : '') + h.position_name"></span>
<span x-show="h.is_active"
style="display: inline-block; margin-left: 4px; padding: 0 5px; background: #ede9fe; color: #7c3aed; border-radius: 10px; font-size: 10px;">在职中</span>
</div>
<div style="color: #9ca3af; font-size: 10px; margin-top: 2px;">
<span x-text="h.appointed_at"></span>
<span> </span>
<span x-text="h.is_active ? '至今' : (h.revoked_at || '')"></span>
<span style="margin-left: 4px;" x-text="'(' + h.duration_days + ' 天)'"></span>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
{{-- 普通操作按鈕:写私信 + 送花 + 内联礼物面板 --}}
<div x-data="{ showGiftPanel: false }" x-show="userInfo.username !== window.chatContext.username">
<div class="modal-actions" style="margin-bottom: 0;">
{{-- 写私信 --}}
<a class="btn-mail"
:href="'{{ route('guestbook.index', ['tab' => 'outbox']) }}&to=' + encodeURIComponent(userInfo
.username)"
target="_blank">
写私信
</a>
{{-- 送花按鈕(与写私信并列) --}}
<button class="btn-whisper" x-on:click="showGiftPanel = !showGiftPanel">
🎁 送礼物
</button>
</div>
{{-- 内联礼物面板 --}}
<div x-show="showGiftPanel" x-transition
style="display: none;
padding: 12px 16px; background: #fff; border-top: 1px solid #f1f5f9;">
<div
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-size: 13px; color: #334155; font-weight: bold;">🎁 选择礼物</span>
<button x-on:click="showGiftPanel = false"
style="background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 18px; line-height: 1;">×</button>
</div>
{{-- 礼物选择列表 --}}
<div
style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 12px; max-height: 180px; overflow-y: auto;">
<template x-for="g in gifts" :key="g.id">
<div x-on:click="selectedGiftId = g.id"
:style="selectedGiftId === g.id ?
'border-color: #f43f5e; background: #fff1f2; box-shadow: 0 4px 12px rgba(244,63,94,0.15);' :
'border-color: #e2e8f0; background: #fff;'"
style="border: 2px solid; padding: 8px 4px; border-radius: 8px; text-align: center; cursor: pointer; transition: all 0.15s;">
<img :src="'/images/gifts/' + g.image"
style="width: 36px; height: 36px; object-fit: contain; margin-bottom: 4px;"
:alt="g.name">
<div style="font-size: 11px; color: #1e293b; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
x-text="g.name"></div>
<div style="font-size: 10px; color: #e11d48;" x-text="g.cost + ' 💰'"></div>
</div>
</template>
</div>
{{-- 数量 + 送出 --}}
<div style="display: flex; gap: 8px; align-items: center;">
<select x-model.number="giftCount"
style="width: 70px; height: 36px; padding: 0 8px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 13px; color: #334155;">
<option value="1">1 </option>
<option value="5">5 </option>
<option value="10">10 </option>
<option value="66">66 </option>
<option value="99">99 </option>
<option value="520">520 </option>
</select>
<button x-on:click="sendGift(); showGiftPanel = false;" :disabled="sendingGift"
style="flex:1; height: 36px; background: linear-gradient(135deg,#f43f5e,#be123c); color:#fff;
border: none; border-radius: 6px; font-size: 14px; font-weight: bold; cursor: pointer;"
:style="sendingGift ? 'opacity:0.7; cursor:not-allowed;' : ''">
<span x-text="sendingGift ? '正在送出...' : '💝 确认赠送'"></span>
</button>
</div>
</div>
</div>
{{-- 管理操作 + 职务操作 合并折叠区 --}}
@if (
$myLevel >= $levelWarn ||
$room->master == Auth::user()->username ||
Auth::user()->activePosition ||
$myLevel >= $superLevel)
<div style="padding: 0 16px 12px;" x-show="userInfo.username !== window.chatContext.username">
{{-- 折叠标题 --}}
<div x-on:click="showAdminPanel = !showAdminPanel"
style="display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px; background: #fef2f2; border: 1px solid #fecaca;
border-radius: 6px; cursor: pointer; user-select: none;">
<span style="font-size: 11px; color: #c00; font-weight: bold;">🔧 管理操作</span>
<span x-text="showAdminPanel ? '▲' : '▼'"
style="font-size: 10px; color: #c00; opacity: 0.6;"></span>
</div>
{{-- 折叠内容 --}}
<div x-show="showAdminPanel" x-transition style="display: none; margin-top: 6px;">
@if ($myLevel >= $levelWarn || $room->master == Auth::user()->username)
<div x-show="userInfo.user_level < {{ $myLevel }}">
<div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;">
管理员操作</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px;">
@if ($myLevel >= $levelWarn)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef3c7; border: 1px solid #f59e0b; cursor: pointer;"
x-on:click="warnUser()">⚠️ 警告</button>
@endif
@if ($myLevel >= $levelKick)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fee2e2; border: 1px solid #ef4444; cursor: pointer;"
x-on:click="kickUser()">🚫 踢出</button>
@endif
@if ($myLevel >= $levelMute)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #e0e7ff; border: 1px solid #6366f1; cursor: pointer;"
x-on:click="isMuting = !isMuting">🔇 禁言</button>
@endif
@if ($myLevel >= $levelFreeze)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #dbeafe; border: 1px solid #3b82f6; cursor: pointer;"
x-on:click="freezeUser()">🧊 冻结</button>
@endif
@if ($myLevel >= $superLevel)
<button
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #f3e8ff; border: 1px solid #a855f7; cursor: pointer;"
x-on:click="loadWhispers()">🔍 私信</button>
@endif
</div>
</div>
@endif
@if (Auth::user()->activePosition || $myLevel >= $superLevel)
<div>
<div style="font-size: 10px; color: #9ca3af; margin-bottom: 4px; padding-left: 2px;">
职务操作</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
<template x-if="!userInfo.position_name">
<button x-on:click="showAppointPanel = !showAppointPanel"
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #f3e8ff; border: 1px solid #a855f7; cursor: pointer;">
任命职务</button>
</template>
<template x-if="userInfo.position_name">
<button x-on:click="doRevoke()" :disabled="appointLoading"
style="flex:1; padding: 5px; border-radius: 4px; font-size: 11px; background: #fef9c3; border: 1px solid #eab308; cursor: pointer;">🔧
撤销职务</button>
</template>
</div>
<div x-show="showAppointPanel" x-transition
style="display:none; margin-top:8px; padding:10px; background:#faf5ff; border:1px solid #d8b4fe; border-radius:6px;">
<div style="font-size:11px; color:#7c3aed; margin-bottom:6px;">选择职务:</div>
<select x-model.number="selectedPositionId"
style="width:100%; padding:4px; border:1px solid #c4b5fd; border-radius:4px; font-size:11px; margin-bottom:6px;">
<template x-for="p in appointPositions" :key="p.id">
<option :value="p.id"
x-text="(p.icon?p.icon+' ':'')+p.department+' · '+p.name"></option>
</template>
</select>
<input type="text" x-model="appointRemark" placeholder="备注(如任命原因)"
style="width:100%; padding:4px; border:1px solid #c4b5fd; border-radius:4px; font-size:11px; box-sizing:border-box; margin-bottom:6px;">
<div style="display:flex; gap:6px;">
<button x-on:click="doAppoint()"
:disabled="appointLoading || !selectedPositionId"
style="flex:1; padding:5px; background:#7c3aed; color:#fff; border:none; border-radius:4px; font-size:11px; cursor:pointer;">
<span x-text="appointLoading?'处理中...':'✅ 确认任命'"></span>
</button>
<button x-on:click="showAppointPanel=false"
style="padding:5px 10px; background:#fff; border:1px solid #ccc; border-radius:4px; font-size:11px; cursor:pointer;">取消</button>
</div>
</div>
</div>
@endif
</div>{{-- /折叠内容 --}}
{{-- 禁言输入表单 --}}
<div x-show="isMuting" style="display:none; margin-top:6px;">
<div style="display:flex; gap:6px; align-items:center;">
<input type="number" x-model="muteDuration" min="1" max="1440"
placeholder="分钟"
style="width:60px; padding:4px; border:1px solid #ccc; border-radius:3px; font-size:11px;">
<span style="font-size:11px; color:#b86e00;">分钟</span>
<button x-on:click="muteUser()"
style="padding:4px 12px; background:#6366f1; color:#fff; border:none; border-radius:3px; font-size:11px; cursor:pointer;">执行</button>
</div>
</div>
</div>
@endif
{{-- 私信记录展示区(管理员查看) --}}
<div x-show="showWhispers"
style="display: none; padding: 0 16px 12px; max-height: 200px; overflow-y: auto;">
<div style="font-size: 11px; color: #666; margin-bottom: 4px;"
x-text="'最近 ' + whisperList.length + ' 条悄悄话:'"></div>
<template x-for="w in whisperList" :key="w.id">
<div style="font-size: 11px; padding: 3px 0; border-bottom: 1px solid #f0f0f0;">
<span style="color: #6366f1;" x-text="w.from_user"></span>
<span style="color: #059669;" x-text="w.to_user"></span>
<span x-text="w.content"></span>
<span style="color: #aaa; font-size: 10px;" x-text="'(' + w.sent_at + ')'"></span>
</div>
</template>
<div x-show="whisperList.length === 0" style="font-size: 11px; color: #aaa;">暂无悄悄话记录</div>
</div>
</div>
</div>
</div>