clean ui
This commit is contained in:
210
management/src/components/AppTemplatesSection.tsx
Normal file
210
management/src/components/AppTemplatesSection.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiAppTemplates, type AppTemplateItem } from '../api';
|
||||
import { AppPoolModal } from './AppPoolModal';
|
||||
import { countKeywords, mergeKeywordCSV } from '../utils/appKeywords';
|
||||
|
||||
export function AppTemplatesSection() {
|
||||
const [items, setItems] = useState<AppTemplateItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<AppTemplateItem | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [poolOpen, setPoolOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiAppTemplates.list();
|
||||
setItems(res.data || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true);
|
||||
setEditing(null);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setKeywords('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const openEdit = (tpl: AppTemplateItem) => {
|
||||
setEditing(tpl);
|
||||
setCreating(false);
|
||||
setName(tpl.name);
|
||||
setDescription(tpl.description || '');
|
||||
setKeywords(tpl.keywords);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Nhập tên khung.');
|
||||
return;
|
||||
}
|
||||
if (!keywords.trim()) {
|
||||
setError('Thêm ít nhất một keyword.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
if (editing) {
|
||||
await apiAppTemplates.update(editing.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
keywords,
|
||||
});
|
||||
} else {
|
||||
await apiAppTemplates.create({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
keywords,
|
||||
});
|
||||
}
|
||||
closeForm();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Lỗi lưu khung');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (tpl: AppTemplateItem) => {
|
||||
if (!confirm(`Xóa khung "${tpl.name}"?`)) return;
|
||||
try {
|
||||
await apiAppTemplates.delete(tpl.id);
|
||||
if (editing?.id === tpl.id) closeForm();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Không xóa được');
|
||||
}
|
||||
};
|
||||
|
||||
const addPoolKeyword = (kw: string) => {
|
||||
setKeywords((prev) => mergeKeywordCSV(prev, kw));
|
||||
};
|
||||
|
||||
const showForm = creating || editing;
|
||||
|
||||
return (
|
||||
<div className="system-section">
|
||||
<div className="system-block">
|
||||
<div className="system-block-head">
|
||||
<div>
|
||||
<h2 className="system-block-title">Khung ứng dụng</h2>
|
||||
<p className="system-block-desc">
|
||||
Tạo bộ keyword whitelist dùng chung — ghép từ kho app hoặc tự nhập. Lớp học và phòng thi có thể áp dụng nhanh thay vì chỉ chọn từng app trong kho.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||||
+ Tạo khung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="system-form-card">
|
||||
<h3 className="system-form-title">{editing ? 'Sửa khung' : 'Khung mới'}</h3>
|
||||
<div className="system-form-grid">
|
||||
<label className="login-field">
|
||||
<span>Tên khung</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="VD: Java IDE, Thi cuối kỳ..."
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field system-form-full">
|
||||
<span>Mô tả (tuỳ chọn)</span>
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Ghi chú ngắn cho giáo viên"
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field system-form-full">
|
||||
<span>Keywords (phân tách bằng dấu phẩy)</span>
|
||||
<textarea
|
||||
className="app-textarea"
|
||||
rows={4}
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
placeholder="chrome, vscode, cursor, goland, client"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="system-form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
|
||||
Thêm từ kho app
|
||||
</button>
|
||||
<div className="system-form-actions-right">
|
||||
<button type="button" className="btn btn-ghost" onClick={closeForm}>
|
||||
Hủy
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
|
||||
{busy ? 'Đang lưu...' : 'Lưu khung'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="login-error" style={{ marginTop: '0.75rem' }}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="system-empty">Đang tải...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="system-empty">
|
||||
Chưa có khung nào. Bấm <strong>Tạo khung</strong> để ghép keyword từ kho hoặc tự ghi.
|
||||
</div>
|
||||
) : (
|
||||
<div className="template-grid">
|
||||
{items.map((tpl) => (
|
||||
<article key={tpl.id} className="template-card">
|
||||
<div className="template-card-head">
|
||||
<h3>{tpl.name}</h3>
|
||||
<span className="template-card-badge">{countKeywords(tpl.keywords)} kw</span>
|
||||
</div>
|
||||
{tpl.description && <p className="template-card-desc">{tpl.description}</p>}
|
||||
<code className="template-card-kw">{tpl.keywords}</code>
|
||||
<div className="template-card-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openEdit(tpl)}>
|
||||
Sửa
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(tpl)}>
|
||||
Xóa
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AppPoolModal
|
||||
open={poolOpen}
|
||||
onClose={() => setPoolOpen(false)}
|
||||
onSelect={addPoolKeyword}
|
||||
allowedApps={keywords}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user