feat: add new file

This commit is contained in:
Daniel
2026-03-24 10:35:58 +08:00
commit 2788fc468f
9058 changed files with 896924 additions and 0 deletions

680
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,680 @@
import { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Line, LineChart, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
import { BookOpen, Bot, ChartNoAxesCombined, NotebookPen } from "lucide-react";
const api = axios.create({ baseURL: "/" });
const CATEGORIES = ["常识", "数量关系", "言语理解", "判断推理", "资料分析"];
const MISTAKE_CATEGORIES = ["常识", "言语", "数量", "判断", "资料", "科学", "其他"];
function formatDate(date) {
return new Date(date).toISOString().slice(0, 10);
}
function getApiErrorMessage(error, fallback = "请求失败,请稍后重试") {
return error?.response?.data?.detail || error?.message || fallback;
}
function useToast() {
const [message, setMessage] = useState("");
const show = (msg) => {
setMessage(msg);
setTimeout(() => setMessage(""), 2500);
};
return { message, show };
}
function ResourceModule() {
const [items, setItems] = useState([]);
const [selected, setSelected] = useState([]);
const [filters, setFilters] = useState({ q: "", category: "", tags: "", resource_type: "", sort_by: "created_at", order: "desc" });
const [batch, setBatch] = useState({ category: CATEGORIES[0], tags: "" });
const [uploading, setUploading] = useState(false);
const { message, show } = useToast();
const [form, setForm] = useState({
title: "",
resource_type: "link",
url: "",
file_name: "",
category: CATEGORIES[0],
tags: ""
});
const load = async () => {
try {
const res = await api.get("/api/resources", { params: filters });
setItems(res.data);
setSelected((prev) => prev.filter((id) => res.data.some((x) => x.id === id)));
} catch (error) {
show(getApiErrorMessage(error, "加载资源失败"));
}
};
useEffect(() => {
load();
}, [filters.category, filters.order, filters.q, filters.resource_type, filters.sort_by, filters.tags]);
const uploadFile = async (file) => {
if (!file) return;
const fd = new FormData();
fd.append("file", file);
setUploading(true);
try {
const res = await api.post("/api/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
setForm((prev) => ({ ...prev, resource_type: "file", url: res.data.url, file_name: res.data.original_name || file.name }));
show("文件上传成功");
} finally {
setUploading(false);
}
};
const submit = async (e) => {
e.preventDefault();
if (form.resource_type === "link" && !form.url) return show("链接类型需填写 URL");
if (form.resource_type === "file" && !form.url) return show("文件类型请先上传文件");
try {
await api.post("/api/resources", form);
setForm({ ...form, title: "", url: "", file_name: "", tags: "" });
show("资源保存成功");
load();
} catch (error) {
show(getApiErrorMessage(error, "保存资源失败"));
}
};
const editItem = async (item) => {
const title = window.prompt("修改资源标题", item.title);
if (!title) return;
await api.put(`/api/resources/${item.id}`, { ...item, title });
show("资源已更新");
load();
};
const remove = async (id) => {
if (!window.confirm("确认删除该资源?")) return;
await api.delete(`/api/resources/${id}`);
show("资源已删除");
load();
};
const toggleSelected = (id) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const batchUpdate = async () => {
if (!selected.length) return show("请先勾选资源");
await api.patch("/api/resources/batch", { ids: selected, category: batch.category, tags: batch.tags || null });
show("批量更新成功");
load();
};
const batchDelete = async () => {
if (!selected.length) return show("请先勾选资源");
if (!window.confirm(`确认批量删除 ${selected.length} 条资源?`)) return;
await api.post("/api/resources/batch-delete", { ids: selected });
show("批量删除成功");
load();
};
return (
<div>
<form className="card" onSubmit={submit}>
<h3>新增资源</h3>
<div className="row">
<input placeholder="资源标题" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required />
<select value={form.resource_type} onChange={(e) => setForm({ ...form, resource_type: e.target.value })}>
<option value="link">链接</option>
<option value="file">文件</option>
</select>
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<input placeholder="链接地址(链接类型必填)" value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} />
<input placeholder="文件名(文件类型选填)" value={form.file_name} onChange={(e) => setForm({ ...form, file_name: e.target.value })} />
<input placeholder="标签,逗号分隔" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
</div>
<div style={{ marginTop: 10 }}>
<input type="file" onChange={(e) => uploadFile(e.target.files?.[0])} />
<span className="muted">{uploading ? "上传中..." : "支持 PDF/Word/JPG/PNG/WebP<=50MB"}</span>
</div>
<div style={{ marginTop: 10 }}>
<button className="primary" type="submit">
保存资源
</button>
</div>
</form>
<div className="card">
<h3>检索与批量操作</h3>
<div className="row">
<input placeholder="搜索标题/标签/链接关键词" value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
<select value={filters.category} onChange={(e) => setFilters({ ...filters, category: e.target.value })}>
<option value="">全部分类</option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<input placeholder="按标签筛选" value={filters.tags} onChange={(e) => setFilters({ ...filters, tags: e.target.value })} />
<select value={filters.resource_type} onChange={(e) => setFilters({ ...filters, resource_type: e.target.value })}>
<option value="">全部类型</option>
<option value="link">链接</option>
<option value="file">文件</option>
</select>
<select value={filters.sort_by} onChange={(e) => setFilters({ ...filters, sort_by: e.target.value })}>
<option value="created_at">按创建时间</option>
<option value="name">按名称</option>
</select>
<select value={filters.order} onChange={(e) => setFilters({ ...filters, order: e.target.value })}>
<option value="desc">降序</option>
<option value="asc">升序</option>
</select>
</div>
<div className="row" style={{ marginTop: 10 }}>
<select value={batch.category} onChange={(e) => setBatch({ ...batch, category: e.target.value })}>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<input placeholder="批量标签(覆盖)" value={batch.tags} onChange={(e) => setBatch({ ...batch, tags: e.target.value })} />
<button className="primary" type="button" onClick={batchUpdate}>
批量分类/打标签
</button>
</div>
<div style={{ marginTop: 10 }}>
<button className="danger" type="button" onClick={batchDelete}>
批量删除
</button>
</div>
</div>
<div className="card">
<h3>资源列表</h3>
{items.map((item) => (
<div className="list-item" key={item.id}>
<div>
<input type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} />
<div>{item.title}</div>
<div className="muted">
{item.category} | {item.resource_type} | {item.tags || "无标签"}
</div>
{item.url && (
<div>
<a href={item.url} target="_blank" rel="noreferrer">
打开资源
</a>
</div>
)}
</div>
<div className="action-group">
<button className="primary" onClick={() => editItem(item)}>
编辑
</button>
<button className="danger" onClick={() => remove(item.id)}>
删除
</button>
</div>
</div>
))}
{items.length === 0 && <div className="muted">暂无数据</div>}
</div>
{message && <div className="toast">{message}</div>}
</div>
);
}
function MistakeModule() {
const [items, setItems] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [analysis, setAnalysis] = useState("");
const [filters, setFilters] = useState({ category: "", keyword: "", sort_by: "created_at", order: "desc" });
const [uploading, setUploading] = useState(false);
const { message, show } = useToast();
const [form, setForm] = useState({
title: "",
image_url: "",
category: MISTAKE_CATEGORIES[0],
difficulty: "medium",
note: "",
wrong_count: 1
});
const load = async () => {
const res = await api.get("/api/mistakes", { params: filters });
setItems(res.data);
};
useEffect(() => {
load();
}, [filters.category, filters.keyword, filters.order, filters.sort_by]);
const uploadImage = async (file) => {
if (!file) return;
const fd = new FormData();
fd.append("file", file);
setUploading(true);
try {
const res = await api.post("/api/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
setForm((prev) => ({ ...prev, image_url: res.data.url }));
show("错题图片上传成功");
} finally {
setUploading(false);
}
};
const submit = async (e) => {
e.preventDefault();
await api.post("/api/mistakes", form);
setForm({ ...form, title: "", image_url: "", note: "" });
show("错题保存成功");
load();
};
const editItem = async (item) => {
const note = window.prompt("修改错题备注", item.note || "");
if (note === null) return;
await api.put(`/api/mistakes/${item.id}`, { ...item, note });
show("错题已更新");
load();
};
const remove = async (id) => {
if (!window.confirm("确认删除该错题?")) return;
await api.delete(`/api/mistakes/${id}`);
show("错题已删除");
load();
};
const exportFile = (type) => {
const params = new URLSearchParams();
if (filters.category) params.set("category", filters.category);
window.open(`/api/mistakes/export/${type}?${params.toString()}`, "_blank");
};
const askAi = async () => {
if (!selectedId) return show("请先选择一条错题");
try {
const res = await api.post(`/api/ai/mistakes/${selectedId}/analyze`);
setAnalysis(res.data.analysis);
} catch (error) {
show(getApiErrorMessage(error, "AI 错题解析失败"));
}
};
return (
<div>
<form className="card" onSubmit={submit}>
<h3>新增错题</h3>
<div className="row">
<input placeholder="题目标题" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required />
<input placeholder="图片 URL可选上传后自动填充" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{MISTAKE_CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select value={form.difficulty} onChange={(e) => setForm({ ...form, difficulty: e.target.value })}>
<option value="easy"></option>
<option value="medium"></option>
<option value="hard"></option>
</select>
<input
type="number"
min={1}
value={form.wrong_count}
onChange={(e) => setForm({ ...form, wrong_count: Number(e.target.value || 1) })}
/>
<textarea placeholder="知识点备注最多500字" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</div>
<div style={{ marginTop: 10 }}>
<input type="file" accept="image/*" onChange={(e) => uploadImage(e.target.files?.[0])} />
<span className="muted">{uploading ? "上传中..." : "支持错题图片上传与替换"}</span>
</div>
<div style={{ marginTop: 10 }}>
<button className="primary" type="submit">
保存错题
</button>
</div>
</form>
<div className="card">
<h3>错题筛选 / 导出 / AI</h3>
<div className="row">
<select value={filters.category} onChange={(e) => setFilters({ ...filters, category: e.target.value })}>
<option value="">全部分类</option>
{MISTAKE_CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<input placeholder="按知识点关键词检索" value={filters.keyword} onChange={(e) => setFilters({ ...filters, keyword: e.target.value })} />
<select value={filters.sort_by} onChange={(e) => setFilters({ ...filters, sort_by: e.target.value })}>
<option value="created_at">按录入时间</option>
<option value="wrong_count">按错误频次</option>
</select>
<select value={filters.order} onChange={(e) => setFilters({ ...filters, order: e.target.value })}>
<option value="desc">降序</option>
<option value="asc">升序</option>
</select>
</div>
<div style={{ marginTop: 10 }} className="action-group">
<button className="primary" type="button" onClick={() => exportFile("pdf")}>
导出 PDF
</button>
<button className="primary" type="button" onClick={() => exportFile("docx")}>
导出 Word
</button>
<button className="primary" type="button" onClick={askAi}>
AI 错题解析千问
</button>
</div>
{analysis && (
<div style={{ marginTop: 12 }}>
<h4>AI 解析结果</h4>
<pre className="pre-wrap">{analysis}</pre>
</div>
)}
</div>
<div className="card">
<h3>错题列表</h3>
{items.map((item) => (
<div className={`list-item ${selectedId === item.id ? "selected" : ""}`} key={item.id}>
<div>
<input type="radio" name="pickedMistake" checked={selectedId === item.id} onChange={() => setSelectedId(item.id)} />
<div>{item.title}</div>
<div className="muted">
{item.category} | {item.difficulty || "无难度"} | 错误频次 {item.wrong_count}
</div>
{item.image_url && (
<div>
<a href={item.image_url} target="_blank" rel="noreferrer">
查看错题图片
</a>
</div>
)}
</div>
<div className="action-group">
<button className="primary" onClick={() => editItem(item)}>
编辑
</button>
<button className="danger" onClick={() => remove(item.id)}>
删除
</button>
</div>
</div>
))}
{items.length === 0 && <div className="muted">暂无数据</div>}
</div>
{message && <div className="toast">{message}</div>}
</div>
);
}
function ScoreModule() {
const [items, setItems] = useState([]);
const [stats, setStats] = useState({ highest: 0, lowest: 0, average: 0, improvement: 0 });
const [filters, setFilters] = useState({ start_date: "", end_date: "" });
const { message, show } = useToast();
const [form, setForm] = useState({
exam_name: "",
exam_date: new Date().toISOString().slice(0, 10),
total_score: 100,
module_scores: ""
});
const load = async () => {
const scoreParams = {};
if (filters.start_date) scoreParams.start_date = filters.start_date;
if (filters.end_date) scoreParams.end_date = filters.end_date;
try {
const [scoresRes, statsRes] = await Promise.all([api.get("/api/scores", { params: scoreParams }), api.get("/api/scores/stats")]);
setItems(scoresRes.data);
setStats(statsRes.data);
} catch (error) {
show(getApiErrorMessage(error, "加载成绩数据失败"));
}
};
useEffect(() => {
load();
}, [filters.end_date, filters.start_date]);
const submit = async (e) => {
e.preventDefault();
try {
await api.post("/api/scores", { ...form, total_score: Number(form.total_score) });
setForm({ ...form, exam_name: "", total_score: 100, module_scores: "" });
show("成绩记录已保存");
load();
} catch (error) {
show(getApiErrorMessage(error, "保存成绩失败"));
}
};
const editItem = async (item) => {
const score = Number(window.prompt("修改总分(0-200)", String(item.total_score)));
if (Number.isNaN(score)) return;
await api.put(`/api/scores/${item.id}`, { ...item, total_score: score });
show("成绩已更新");
load();
};
const remove = async (id) => {
if (!window.confirm("确认删除该记录?")) return;
await api.delete(`/api/scores/${id}`);
show("成绩已删除");
load();
};
const chartData = useMemo(
() =>
items.map((item) => ({
date: item.exam_date,
score: item.total_score
})),
[items]
);
const quickRange = (days) => {
const end = new Date();
const start = new Date(Date.now() - days * 24 * 3600 * 1000);
setFilters({ start_date: formatDate(start), end_date: formatDate(end) });
};
return (
<div>
<form className="card" onSubmit={submit}>
<h3>新增模考记录</h3>
<div className="row">
<input placeholder="考试名称" value={form.exam_name} onChange={(e) => setForm({ ...form, exam_name: e.target.value })} required />
<input type="date" value={form.exam_date} onChange={(e) => setForm({ ...form, exam_date: e.target.value })} required />
<input
type="number"
min={0}
max={200}
value={form.total_score}
onChange={(e) => setForm({ ...form, total_score: Number(e.target.value || 0) })}
required
/>
<input
placeholder="模块分数字符串(如 常识:20,言语:35"
value={form.module_scores}
onChange={(e) => setForm({ ...form, module_scores: e.target.value })}
/>
</div>
<div style={{ marginTop: 10 }}>
<button className="primary" type="submit">
保存成绩
</button>
</div>
</form>
<div className="card">
<h3>时间筛选</h3>
<div className="row">
<input type="date" value={filters.start_date} onChange={(e) => setFilters({ ...filters, start_date: e.target.value })} />
<input type="date" value={filters.end_date} onChange={(e) => setFilters({ ...filters, end_date: e.target.value })} />
<button className="primary" type="button" onClick={() => quickRange(7)}>
7
</button>
<button className="primary" type="button" onClick={() => quickRange(30)}>
30
</button>
<button className="primary" type="button" onClick={() => setFilters({ start_date: "", end_date: "" })}>
清空筛选
</button>
</div>
</div>
<div className="card">
<h3>分数趋势</h3>
<div style={{ width: "100%", height: 260 }}>
<ResponsiveContainer>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 200]} />
<Tooltip />
<Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="muted">
最高分: {stats.highest} | 最低分: {stats.lowest} | 平均分: {stats.average} | 提升分: {stats.improvement}
</div>
</div>
<div className="card">
<h3>成绩记录</h3>
{items.map((item) => (
<div className="list-item" key={item.id}>
<div>
<div>{item.exam_name}</div>
<div className="muted">
{item.exam_date} | {item.total_score}
</div>
</div>
<div className="action-group">
<button className="primary" onClick={() => editItem(item)}>
编辑
</button>
<button className="danger" onClick={() => remove(item.id)}>
删除
</button>
</div>
</div>
))}
{items.length === 0 && <div className="muted">暂无数据</div>}
</div>
{message && <div className="toast">{message}</div>}
</div>
);
}
function AiModule() {
const { message, show } = useToast();
const [form, setForm] = useState({ goal: "30天内行测稳定到70分以上", days_left: 30, daily_hours: 2 });
const [plan, setPlan] = useState("");
const submit = async (e) => {
e.preventDefault();
try {
const res = await api.post("/api/ai/study-plan", form);
setPlan(res.data.plan);
show("学习计划已生成");
} catch (error) {
show(getApiErrorMessage(error, "AI 学习计划生成失败"));
}
};
return (
<div>
<form className="card" onSubmit={submit}>
<h3>AI 学习计划千问</h3>
<div className="row">
<input value={form.goal} onChange={(e) => setForm({ ...form, goal: e.target.value })} placeholder="目标" />
<input
type="number"
min={1}
max={365}
value={form.days_left}
onChange={(e) => setForm({ ...form, days_left: Number(e.target.value || 30) })}
/>
<input
type="number"
min={0.5}
max={16}
step={0.5}
value={form.daily_hours}
onChange={(e) => setForm({ ...form, daily_hours: Number(e.target.value || 2) })}
/>
</div>
<div style={{ marginTop: 10 }}>
<button className="primary" type="submit">
生成学习计划
</button>
</div>
</form>
<div className="card">
<h3>计划输出</h3>
<pre className="pre-wrap">{plan || "暂无结果。请先生成计划。"}</pre>
</div>
{message && <div className="toast">{message}</div>}
</div>
);
}
export default function App() {
const [activeTab, setActiveTab] = useState("resource");
const tabs = [
{ key: "resource", label: "资源", icon: BookOpen },
{ key: "mistake", label: "错题", icon: NotebookPen },
{ key: "score", label: "过程", icon: ChartNoAxesCombined },
{ key: "ai", label: "AI", icon: Bot }
];
return (
<div className="app-page">
<div className="container">
<div className="app-header">
<h1>公考助手</h1>
<p className="muted">移动版 · 支持 WebView 套壳</p>
</div>
<div className="tabs tabs-top">
{tabs.map((tab) => (
<button key={tab.key} className={activeTab === tab.key ? "active" : ""} onClick={() => setActiveTab(tab.key)} aria-label={tab.label}>
<tab.icon size={16} /> {tab.label}
</button>
))}
</div>
{activeTab === "resource" && <ResourceModule />}
{activeTab === "mistake" && <MistakeModule />}
{activeTab === "score" && <ScoreModule />}
{activeTab === "ai" && <AiModule />}
</div>
<div className="tabs tabs-bottom">
{tabs.map((tab) => (
<button key={tab.key} className={activeTab === tab.key ? "active" : ""} onClick={() => setActiveTab(tab.key)} aria-label={tab.label}>
<span className="tab-icon-wrap">
<tab.icon size={18} />
</span>
<small>{tab.label}</small>
</button>
))}
</div>
</div>
);
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

296
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,296 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: radial-gradient(circle at top, #1e3a8a 0%, #0b1223 40%, #050914 100%);
color: #e5e7eb;
letter-spacing: 0.1px;
}
input,
select,
textarea,
button {
font: inherit;
}
.app-page {
min-height: 100vh;
padding-bottom: calc(82px + env(safe-area-inset-bottom));
}
.container {
max-width: 980px;
margin: 0 auto;
padding: 14px 12px 20px;
}
.app-header {
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(8px);
background: rgba(3, 7, 18, 0.65);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
padding: 14px;
margin-bottom: 12px;
}
h1 {
margin: 0;
color: #f9fafb;
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
h3,
h4 {
margin-top: 0;
color: #f9fafb;
}
.tabs button {
border: 1px solid rgba(255, 255, 255, 0.14);
padding: 10px 11px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
color: #d1d5db;
cursor: pointer;
transition: all 0.18s ease;
}
.tabs button.active {
background: linear-gradient(120deg, #2563eb, #4f46e5);
border-color: transparent;
color: #fff;
}
.tabs-top {
display: none;
margin-bottom: 14px;
gap: 8px;
flex-wrap: wrap;
}
.tabs-top button {
display: inline-flex;
align-items: center;
gap: 6px;
}
.tabs-bottom {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
background: rgba(2, 6, 23, 0.95);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.tabs-bottom button {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 4px;
min-height: 58px;
}
.tabs-bottom small {
font-size: 11px;
margin-top: 3px;
}
.tab-icon-wrap {
width: 26px;
height: 26px;
border-radius: 8px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.08);
}
.tabs-bottom button.active .tab-icon-wrap {
background: rgba(255, 255, 255, 0.2);
}
.card {
background: rgba(15, 23, 42, 0.86);
border-radius: 16px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 12px;
box-shadow: 0 8px 22px rgba(2, 6, 23, 0.32);
}
.row {
display: grid;
grid-template-columns: 1fr;
gap: 9px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
padding: 11px 12px;
background: rgba(255, 255, 255, 0.04);
color: #f3f4f6;
}
input::placeholder,
textarea::placeholder {
color: #9ca3af;
}
textarea {
min-height: 88px;
}
button.primary {
background: linear-gradient(120deg, #2563eb, #4f46e5);
color: white;
border: none;
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
min-height: 42px;
font-weight: 600;
}
button.danger {
background: linear-gradient(120deg, #ef4444, #dc2626);
color: white;
border: none;
padding: 10px 12px;
border-radius: 12px;
cursor: pointer;
min-height: 42px;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
padding: 13px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.list-item:last-child {
border-bottom: none;
}
.muted {
color: #9ca3af;
font-size: 13px;
line-height: 1.45;
}
.action-group {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.selected {
background: rgba(59, 130, 246, 0.12);
border-radius: 12px;
padding: 10px;
margin: 0 -4px;
}
.toast {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(88px + env(safe-area-inset-bottom));
background: rgba(17, 24, 39, 0.95);
color: #fff;
padding: 10px 14px;
border-radius: 999px;
font-size: 13px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.pre-wrap {
white-space: pre-wrap;
line-height: 1.5;
margin: 0;
background: rgba(0, 0, 0, 0.28);
border-radius: 10px;
padding: 12px;
font-size: 13px;
}
a {
color: #93c5fd;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (min-width: 861px) {
.app-page {
padding-bottom: 20px;
}
.tabs-bottom {
display: none;
}
.tabs-top {
display: flex;
}
.row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 860px) {
.container {
padding: 10px 10px 16px;
}
.app-header {
position: static;
margin-bottom: 10px;
}
.card {
border-radius: 14px;
padding: 12px;
margin-bottom: 10px;
}
h3 {
font-size: 16px;
margin-bottom: 10px;
}
input,
select,
textarea {
font-size: 16px;
}
.action-group {
width: 100%;
}
.action-group button {
flex: 1;
}
.list-item {
flex-direction: column;
}
.tabs-top {
display: none;
}
}