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。
|
- **Secrets** 标签 → `TELEGRAM_BOT_TOKEN` = [@BotFather](https://t.me/BotFather) 给的 token。
|
||||||
|
|
||||||
之后每次「发布 Release / 新建 issue」都会自动推送。想关掉 issue 推送,删掉 workflow 里 `on:` 下的 `issues:` 即可。
|
之后每次「发布 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">
|
<div className="inst-grid">
|
||||||
{instances.map((inst) => {
|
{instances.map((inst) => {
|
||||||
const st = statusOf(inst);
|
const st = statusOf(inst);
|
||||||
|
const meta = inst.wechat.installed
|
||||||
|
? `微信 ${inst.wechat.version || ''}`.trim()
|
||||||
|
: inst.runtime === 'running'
|
||||||
|
? '待下载安装微信'
|
||||||
|
: '';
|
||||||
return (
|
return (
|
||||||
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
|
<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-av">{inst.name.slice(0, 1)}</span>
|
||||||
<span className="home-card-main">
|
<span className="home-card-main">
|
||||||
<span className="home-card-name">{inst.name}</span>
|
<span className="home-card-name">{inst.name}</span>
|
||||||
<span className={'home-card-st ' + st.cls}>● {st.text}</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>
|
||||||
<span className="enter-arrow">›</span>
|
<span className="enter-arrow">›</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ const MenuIcon = (
|
|||||||
</svg>
|
</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 }) {
|
export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -210,9 +229,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{instances.length === 0 ? (
|
{instances.length === 0 ? (
|
||||||
<div className="list">
|
<EmptyState
|
||||||
<div className="muted small" style={{ padding: '14px 16px' }}>暂无实例</div>
|
icon="🖥️"
|
||||||
</div>
|
title="还没有微信实例"
|
||||||
|
sub="新建一个实例,进入后扫码登录即可在浏览器里用微信"
|
||||||
|
action={
|
||||||
|
<button className="btn btn-primary" onClick={() => setCreatingInst(true)}>
|
||||||
|
+ 新建微信实例
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="inst-grid">
|
<div className="inst-grid">
|
||||||
{instances.map((inst) => (
|
{instances.map((inst) => (
|
||||||
@@ -243,9 +269,16 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{subs.length === 0 ? (
|
{subs.length === 0 ? (
|
||||||
<div className="list">
|
<EmptyState
|
||||||
<div className="muted small" style={{ padding: '14px 16px' }}>暂无子账号</div>
|
icon="👥"
|
||||||
</div>
|
title="还没有子账号"
|
||||||
|
sub="子账号是登录这套面板的身份,可按账号分配能访问哪些实例"
|
||||||
|
action={
|
||||||
|
<button className="btn btn-primary" onClick={() => setCreatingUser(true)}>
|
||||||
|
+ 新建子账号
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="inst-grid">
|
<div className="inst-grid">
|
||||||
{subs.map((u) => (
|
{subs.map((u) => (
|
||||||
@@ -800,6 +833,7 @@ function InstanceAdminCard({
|
|||||||
const installed = wx.installed && wx.phase !== 'downloading';
|
const installed = wx.installed && wx.phase !== 'downloading';
|
||||||
const offline = inst.runtime !== 'running';
|
const offline = inst.runtime !== 'running';
|
||||||
const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片
|
const working = !!acting || busy; // 生命周期操作中 或 微信下载/更新中 → 锁住卡片
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false); // 「管理」折叠菜单是否展开
|
||||||
|
|
||||||
let badge: { text: string; cls: string };
|
let badge: { text: string; cls: string };
|
||||||
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
|
if (acting) badge = { text: '处理中', cls: 'tag-busy' };
|
||||||
@@ -851,41 +885,62 @@ function InstanceAdminCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inst-admin-links">
|
<button className={'inst-menu-toggle' + (menuOpen ? ' open' : '')} onClick={() => setMenuOpen((v) => !v)}>
|
||||||
{!offline && (
|
<span>管理</span>
|
||||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
<span className="inst-menu-caret">{CaretIcon}</span>
|
||||||
{installed ? '更新微信' : '下载安装'}
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
{menuOpen && (
|
||||||
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录),把实例更新到新版">
|
<div className="inst-menu">
|
||||||
升级实例
|
<div className="inst-menu-group">
|
||||||
</button>
|
<div className="inst-menu-label">运维</div>
|
||||||
{!offline && (
|
<div className="inst-menu-items">
|
||||||
<button className="btn-text" onClick={onRestart}>
|
{!offline && (
|
||||||
重启
|
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||||
</button>
|
{installed ? '更新微信' : '下载安装'}
|
||||||
)}
|
</button>
|
||||||
{!offline && (
|
)}
|
||||||
<button className="btn-text" onClick={onStop}>
|
<button className="btn-text" onClick={onUpgrade} title="拉取最新镜像并重建(保留聊天记录)">
|
||||||
停止
|
升级实例
|
||||||
</button>
|
</button>
|
||||||
)}
|
{!offline && (
|
||||||
<button className="btn-text" onClick={onRename}>
|
<button className="btn-text" onClick={onRestart}>
|
||||||
重命名
|
重启
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-text" onClick={onAssign}>
|
)}
|
||||||
分配账户
|
{!offline && (
|
||||||
</button>
|
<button className="btn-text" onClick={onStop}>
|
||||||
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志(排错)">
|
停止
|
||||||
日志
|
</button>
|
||||||
</button>
|
)}
|
||||||
<button className="btn-text" onClick={onSecurity} title="设置内存阈值:超过 soft 且无人会话时柔和重启;超过 hard 强制重启">
|
</div>
|
||||||
安全
|
</div>
|
||||||
</button>
|
<div className="inst-menu-group">
|
||||||
<button className="btn-text danger" onClick={onDelete}>
|
<div className="inst-menu-label">设置</div>
|
||||||
删除
|
<div className="inst-menu-items">
|
||||||
</button>
|
<button className="btn-text" onClick={onRename}>
|
||||||
</div>
|
重命名
|
||||||
|
</button>
|
||||||
|
<button className="btn-text" onClick={onAssign}>
|
||||||
|
分配账户
|
||||||
|
</button>
|
||||||
|
<button className="btn-text" onClick={() => window.open(api.instanceLogsUrl(inst.id), '_blank')} title="查看实例容器日志">
|
||||||
|
日志
|
||||||
|
</button>
|
||||||
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -305,6 +305,67 @@ button {
|
|||||||
background: rgba(var(--danger-rgb) / 0.12);
|
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 {
|
.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -845,6 +906,8 @@ button {
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 22px;
|
margin-bottom: 22px;
|
||||||
|
/* 各卡片按自身内容高度,避免某张展开「管理」菜单时同行其它卡片被拉等高(显得也展开了) */
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
.inst-card {
|
.inst-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -978,6 +1041,9 @@ button {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 30px 16px 36px;
|
padding: 30px 16px 36px;
|
||||||
}
|
}
|
||||||
|
.empty-action {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
.empty-blob {
|
.empty-blob {
|
||||||
width: 96px;
|
width: 96px;
|
||||||
height: 96px;
|
height: 96px;
|
||||||
@@ -1467,6 +1533,10 @@ button {
|
|||||||
background: var(--sheen);
|
background: var(--sheen);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.home-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--crease-accent);
|
||||||
|
}
|
||||||
.home-card:active {
|
.home-card:active {
|
||||||
transform: scale(0.985);
|
transform: scale(0.985);
|
||||||
}
|
}
|
||||||
@@ -1500,10 +1570,29 @@ button {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.home-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.home-card-st {
|
.home-card-st {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: none !important;
|
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