mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
feat(admin/ci): 管理页折叠菜单 + 空状态/主页优化 + Telegram 命令机器人
管理页 UI/UX - 实例卡片操作改为「管理」分类折叠菜单(默认收起,点开按 运维/设置/危险 分组的 文字操作),替代之前难辨认的图标排;删除单独成组、红色,降低误点 - 修复展开一张卡片时同行其它卡片被 grid 拉等高(inst-grid align-items:start) - 管理页空状态(无实例/无子账号)改为图标+标题+说明+引导按钮 - 主页实例卡片加副行(状态·微信版本)、悬停上浮高亮 Telegram 命令机器人(轮询版,纯 GitHub Actions,无服务器) - .github/workflows/telegram-bot.yml + scripts/telegram-bot.mjs - 私聊/群组命令:/help /releases /release <tag> /issues /issue <编号> - cron 每 5 分钟 getUpdates,处理后用 offset 向 Telegram 确认,无需持久化存储 - 受 vars.TELEGRAM_BOT_ENABLED 开关;命令非实时(cron 限制),文档已说明 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// Telegram 命令机器人(轮询版)——跑在 GitHub Actions cron 上,无需任何服务器。
|
||||
//
|
||||
// 工作方式:每次运行调用 getUpdates 拉取「自上次确认以来」的待处理更新,逐条处理命令并回复,
|
||||
// 最后用 offset=最后一条+1 再调一次 getUpdates 向 Telegram「确认」(这些更新随即被服务端清掉,
|
||||
// 下次不再返回)。Telegram 自己保存 offset 状态(未确认的更新保留 24h),因此本脚本无需任何持久化存储。
|
||||
//
|
||||
// 支持私聊与群组(群组里以 /命令 形式发送即可,命令不受 bot 隐私模式影响)。
|
||||
// 命令:/help /releases /release <tag> /issues /issue <编号>
|
||||
//
|
||||
// 局限:受 GitHub cron 最小 5 分钟间隔限制,命令有延迟(非实时)。要实时需改用 webhook(需 serverless 端点)。
|
||||
|
||||
const TG = process.env.TG_TOKEN;
|
||||
const GH = process.env.GH_TOKEN || '';
|
||||
const REPO = process.env.REPO; // owner/repo
|
||||
if (!TG) {
|
||||
console.log('TELEGRAM_BOT_TOKEN 未配置,跳过');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const tgUrl = (method) => `https://api.telegram.org/bot${TG}/${method}`;
|
||||
const ghHeaders = {
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'woc-telegram-bot',
|
||||
...(GH ? { authorization: `Bearer ${GH}` } : {}),
|
||||
};
|
||||
|
||||
async function tg(method, params) {
|
||||
const r = await fetch(tgUrl(method), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
async function gh(path) {
|
||||
const r = await fetch(`https://api.github.com/repos/${REPO}${path}`, { headers: ghHeaders });
|
||||
if (!r.ok) throw new Error(`GitHub ${path} → ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
const trunc = (s, n) => (s && s.length > n ? s.slice(0, n) + '…' : s || '');
|
||||
const send = (chatId, text) => tg('sendMessage', { chat_id: chatId, text, disable_web_page_preview: true });
|
||||
|
||||
const HELP = [
|
||||
'🤖 云微 WechatOnCloud 机器人命令:',
|
||||
'',
|
||||
'/releases — 最近发布列表',
|
||||
'/release <tag> — 某版本详情(省略 = 最新)',
|
||||
'/issues — 打开中的 issue 列表',
|
||||
'/issue <编号> — issue 详情',
|
||||
'/help — 显示本帮助',
|
||||
'',
|
||||
'(轮询版,命令可能有几分钟延迟)',
|
||||
].join('\n');
|
||||
|
||||
async function handle(cmd, arg, chatId) {
|
||||
switch (cmd) {
|
||||
case '/start':
|
||||
case '/help':
|
||||
return send(chatId, HELP);
|
||||
|
||||
case '/releases': {
|
||||
const rels = await gh('/releases?per_page=8');
|
||||
if (!rels.length) return send(chatId, '暂无 release');
|
||||
const lines = rels.map(
|
||||
(r) =>
|
||||
`• ${r.tag_name}${r.name && r.name !== r.tag_name ? ' — ' + r.name : ''} (${(r.published_at || '').slice(0, 10)})`,
|
||||
);
|
||||
return send(chatId, '📦 最近发布:\n' + lines.join('\n') + '\n\n用 /release <tag> 看某版详情');
|
||||
}
|
||||
|
||||
case '/release': {
|
||||
const rel = arg ? await gh(`/releases/tags/${encodeURIComponent(arg)}`) : await gh('/releases/latest');
|
||||
const title = `${rel.tag_name}${rel.name && rel.name !== rel.tag_name ? ' · ' + rel.name : ''}`;
|
||||
return send(
|
||||
chatId,
|
||||
`📦 ${title}\n发布于 ${(rel.published_at || '').slice(0, 10)}\n\n${trunc(rel.body, 2500)}\n\n🔗 ${rel.html_url}`,
|
||||
);
|
||||
}
|
||||
|
||||
case '/issues': {
|
||||
const items = (await gh('/issues?state=open&per_page=10&sort=updated')).filter((i) => !i.pull_request);
|
||||
if (!items.length) return send(chatId, '🎉 当前没有打开的 issue');
|
||||
const lines = items.map((i) => `• #${i.number} ${trunc(i.title, 60)}`);
|
||||
return send(chatId, `🐛 打开中的 issue(${items.length}):\n` + lines.join('\n') + '\n\n用 /issue <编号> 看详情');
|
||||
}
|
||||
|
||||
case '/issue': {
|
||||
if (!arg) return send(chatId, '用法:/issue <编号>');
|
||||
const i = await gh(`/issues/${encodeURIComponent(arg)}`);
|
||||
if (i.pull_request) return send(chatId, `#${arg} 是个 PR,不是 issue`);
|
||||
return send(
|
||||
chatId,
|
||||
`🐛 #${i.number} ${i.title}\n状态:${i.state} · by ${i.user?.login}\n\n${trunc(i.body, 2500)}\n\n🔗 ${i.html_url}`,
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return; // 未知命令静默忽略,避免群里刷屏
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// 短轮询拉取待处理更新(只要 message)
|
||||
const res = await (
|
||||
await fetch(tgUrl('getUpdates') + '?timeout=0&allowed_updates=' + encodeURIComponent('["message"]'))
|
||||
).json();
|
||||
if (!res.ok) {
|
||||
console.error('getUpdates 失败:', JSON.stringify(res));
|
||||
process.exit(res.error_code === 409 ? 0 : 1); // 409 = 设了 webhook,与轮询冲突,直接退出
|
||||
}
|
||||
const updates = res.result || [];
|
||||
let maxId = 0;
|
||||
for (const u of updates) {
|
||||
maxId = Math.max(maxId, u.update_id);
|
||||
const m = u.message;
|
||||
if (!m || !m.text) continue;
|
||||
const text = m.text.trim();
|
||||
if (!text.startsWith('/')) continue;
|
||||
const parts = text.split(/\s+/);
|
||||
const cmd = parts[0].split('@')[0].toLowerCase(); // 去掉 @botname 后缀
|
||||
const arg = parts.slice(1).join(' ').trim();
|
||||
try {
|
||||
await handle(cmd, arg, m.chat.id);
|
||||
} catch (e) {
|
||||
await send(m.chat.id, '⚠️ 出错了:' + (e?.message || e));
|
||||
}
|
||||
}
|
||||
// 向 Telegram 确认已处理(清掉这些更新,下次不再返回)
|
||||
if (maxId) {
|
||||
await fetch(tgUrl('getUpdates') + `?offset=${maxId + 1}&timeout=0`);
|
||||
}
|
||||
console.log(`processed ${updates.length} update(s)`);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
name: telegram-bot
|
||||
|
||||
# Telegram 命令机器人(轮询版)——无需服务器,仅靠 GitHub Actions cron。
|
||||
# 私聊 / 群组都可用命令查询:/help /releases /release <tag> /issues /issue <编号>。
|
||||
#
|
||||
# 启用(一次性):
|
||||
# 1) 已配置 telegram-notify 用到的 TELEGRAM_BOT_TOKEN(Secret)即可复用;
|
||||
# 2) 仓库 Settings → Secrets and variables → Actions → Variables 新建
|
||||
# TELEGRAM_BOT_ENABLED = true (未设为 true 则本工作流不运行,避免空跑)
|
||||
# 3) 把机器人拉进群组 / 在私聊里 /start。
|
||||
#
|
||||
# 局限:cron 最小 5 分钟且可能再延后 → 命令非实时;GitHub 会在仓库 60 天无活动时暂停定时任务。
|
||||
# 想立即处理一次:Actions → telegram-bot → Run workflow(workflow_dispatch)。
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
# 避免轮询任务并发重叠重复处理;新触发取消正在跑的(残留未确认更新下次重处理,无副作用)
|
||||
concurrency:
|
||||
group: telegram-bot
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
poll:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ vars.TELEGRAM_BOT_ENABLED == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Poll Telegram & handle commands
|
||||
env:
|
||||
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: node .github/scripts/telegram-bot.mjs
|
||||
@@ -92,3 +92,20 @@ docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
- **Secrets** 标签 → `TELEGRAM_BOT_TOKEN` = [@BotFather](https://t.me/BotFather) 给的 token。
|
||||
|
||||
之后每次「发布 Release / 新建 issue」都会自动推送。想关掉 issue 推送,删掉 workflow 里 `on:` 下的 `issues:` 即可。
|
||||
|
||||
---
|
||||
|
||||
## Telegram 命令机器人(可选,免服务器,轮询版)
|
||||
|
||||
除了发布通知,仓库还内置 [.github/workflows/telegram-bot.yml](../.github/workflows/telegram-bot.yml) + [.github/scripts/telegram-bot.mjs](../.github/scripts/telegram-bot.mjs):让机器人在**私聊 / 群组**里响应命令,跑在 GitHub Actions cron 上,**不需要服务器**。
|
||||
|
||||
命令:`/help`、`/releases`、`/release <tag>`、`/issues`、`/issue <编号>`。
|
||||
|
||||
启用:
|
||||
|
||||
1. 复用上面的 `TELEGRAM_BOT_TOKEN`(Secret);
|
||||
2. 仓库 **Settings → Secrets and variables → Actions → Variables** 新建 `TELEGRAM_BOT_ENABLED = true`(不设为 `true` 则该工作流不运行);
|
||||
3. 把机器人拉进群组,或在私聊里 `/start`。
|
||||
|
||||
> **原理**:每 5 分钟(cron 最小间隔)调用 `getUpdates` 拉取待处理命令、回复、再用 `offset` 向 Telegram 确认(Telegram 自己保存 offset,**无需任何持久化存储**)。
|
||||
> **局限**:命令**非实时**——受 cron 最小 5 分钟 + GitHub 排队延迟影响;且 GitHub 会在仓库 60 天无活动时暂停定时任务(去 Actions 页手动重启即可)。想立即处理一次:Actions → telegram-bot → **Run workflow**。要真正实时,得改用 webhook(需一个 serverless 端点)。
|
||||
|
||||
@@ -272,12 +272,20 @@ function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; on
|
||||
<div className="inst-grid">
|
||||
{instances.map((inst) => {
|
||||
const st = statusOf(inst);
|
||||
const meta = inst.wechat.installed
|
||||
? `微信 ${inst.wechat.version || ''}`.trim()
|
||||
: inst.runtime === 'running'
|
||||
? '待下载安装微信'
|
||||
: '';
|
||||
return (
|
||||
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
|
||||
<span className="home-card-av">{inst.name.slice(0, 1)}</span>
|
||||
<span className="home-card-main">
|
||||
<span className="home-card-name">{inst.name}</span>
|
||||
<span className="home-card-meta">
|
||||
<span className={'home-card-st ' + st.cls}>● {st.text}</span>
|
||||
{meta && <span className="home-card-ver">{meta}</span>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="enter-arrow">›</span>
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,25 @@ const MenuIcon = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 折叠菜单的展开箭头
|
||||
const CaretIcon = (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 友好空状态:圆形图标 + 标题 + 说明 + 可选引导按钮(沿用首页 .empty-state 样式)
|
||||
function EmptyState({ icon, title, sub, action }: { icon: string; title: string; sub?: string; action?: JSX.Element }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-blob">{icon}</div>
|
||||
<div className="empty-title">{title}</div>
|
||||
{sub && <div className="empty-sub">{sub}</div>}
|
||||
{action && <div className="empty-action">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
@@ -210,9 +229,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
</button>
|
||||
</div>
|
||||
{instances.length === 0 ? (
|
||||
<div className="list">
|
||||
<div className="muted small" style={{ padding: '14px 16px' }}>暂无实例</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="🖥️"
|
||||
title="还没有微信实例"
|
||||
sub="新建一个实例,进入后扫码登录即可在浏览器里用微信"
|
||||
action={
|
||||
<button className="btn btn-primary" onClick={() => setCreatingInst(true)}>
|
||||
+ 新建微信实例
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{instances.map((inst) => (
|
||||
@@ -243,9 +269,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
||||
</button>
|
||||
</div>
|
||||
{subs.length === 0 ? (
|
||||
<div className="list">
|
||||
<div className="muted small" style={{ padding: '14px 16px' }}>暂无子账号</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
title="还没有子账号"
|
||||
sub="子账号是登录这套面板的身份,可按账号分配能访问哪些实例"
|
||||
action={
|
||||
<button className="btn btn-primary" onClick={() => setCreatingUser(true)}>
|
||||
+ 新建子账号
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{subs.map((u) => (
|
||||
@@ -800,6 +833,7 @@ function InstanceAdminCard({
|
||||
const installed = wx.installed && wx.phase !== 'downloading';
|
||||
const offline = inst.runtime !== 'running';
|
||||
const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片
|
||||
const [menuOpen, setMenuOpen] = useState(false); // 「管理」折叠菜单是否展开
|
||||
|
||||
let badge: { text: string; cls: string };
|
||||
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
@@ -851,13 +885,22 @@ function InstanceAdminCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="inst-admin-links">
|
||||
<button className={'inst-menu-toggle' + (menuOpen ? ' open' : '')} onClick={() => setMenuOpen((v) => !v)}>
|
||||
<span>管理</span>
|
||||
<span className="inst-menu-caret">{CaretIcon}</span>
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="inst-menu">
|
||||
<div className="inst-menu-group">
|
||||
<div className="inst-menu-label">运维</div>
|
||||
<div className="inst-menu-items">
|
||||
{!offline && (
|
||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||
{installed ? '更新微信' : '下载安装'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录),把实例更新到新版">
|
||||
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录)">
|
||||
升级实例
|
||||
</button>
|
||||
{!offline && (
|
||||
@@ -870,22 +913,34 @@ function InstanceAdminCard({
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inst-menu-group">
|
||||
<div className="inst-menu-label">设置</div>
|
||||
<div className="inst-menu-items">
|
||||
<button className="btn-text" onClick={onRename}>
|
||||
重命名
|
||||
</button>
|
||||
<button className="btn-text" onClick={onAssign}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
|
||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志">
|
||||
日志
|
||||
</button>
|
||||
<button className="btn-text" onClick={onSecurity} title="设置内存阈值:超过 soft 且无人会话时柔和重启;超过 hard 强制重启">
|
||||
<button className="btn-text" onClick={onSecurity} title="内存阈值自愈">
|
||||
安全
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inst-menu-group inst-menu-danger">
|
||||
<div className="inst-menu-items">
|
||||
<button className="btn-text danger" onClick={onDelete}>
|
||||
删除
|
||||
删除实例
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -305,6 +305,67 @@ button {
|
||||
background: rgba(var(--danger-rgb) / 0.12);
|
||||
}
|
||||
|
||||
/* 实例卡片操作:「管理」分类折叠菜单(默认收起,点开按运维/设置/危险分组展开文字操作) */
|
||||
.inst-menu-toggle {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--trough);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 2px rgba(var(--shadow) / 0.1);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.inst-menu-toggle:hover {
|
||||
background: var(--base);
|
||||
}
|
||||
.inst-menu-caret {
|
||||
display: inline-flex;
|
||||
color: var(--muted);
|
||||
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.inst-menu-toggle.open .inst-menu-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.inst-menu {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
animation: inst-menu-in 0.16s ease;
|
||||
}
|
||||
@keyframes inst-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
.inst-menu-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
margin: 2px 4px 2px;
|
||||
}
|
||||
.inst-menu-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
.inst-menu-danger {
|
||||
margin-top: 2px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(var(--shadow) / 0.08);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
@@ -845,6 +906,8 @@ button {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
/* 各卡片按自身内容高度,避免某张展开「管理」菜单时同行其它卡片被拉等高(显得也展开了) */
|
||||
align-items: start;
|
||||
}
|
||||
.inst-card {
|
||||
position: relative;
|
||||
@@ -978,6 +1041,9 @@ button {
|
||||
text-align: center;
|
||||
padding: 30px 16px 36px;
|
||||
}
|
||||
.empty-action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.empty-blob {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
@@ -1467,6 +1533,10 @@ button {
|
||||
background: var(--sheen);
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--crease-accent);
|
||||
}
|
||||
.home-card:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
@@ -1500,10 +1570,29 @@ button {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.home-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.home-card-st {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: none !important;
|
||||
flex: none;
|
||||
}
|
||||
.home-card-ver {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.home-card-ver::before {
|
||||
content: '·';
|
||||
margin-right: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* —— 内嵌实例视图 —— */
|
||||
|
||||
Reference in New Issue
Block a user