mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-13 20:46:38 +08:00
feat: 添加公告系统(支持SQLite和PostgreSQL) (#129)
* feat(announcement): add database schema for SQLite and PostgreSQL Add announcement table with id, title, content, enabled, created_at, updated_at columns to both SQLite and PostgreSQL schemas to support announcement system. * feat(announcement): implement SQLite repository for announcement management Add announcement CRUD operations in SQLite repository including create, read, update, delete, and list methods with proper error handling. * feat(announcement): add HTTP handlers and auth middleware for announcement API Implement admin-only update endpoint and public read endpoint for announcements. Add auth middleware to enforce admin-only access for update operations. * feat(announcement): add frontend API client for announcement endpoints Implement API client methods for fetching announcements and updating announcement settings with proper error handling. * feat(announcement): add announcement display component to dashboard Implement announcement display section in dashboard with real-time updates and proper styling using HeroUI components. * feat(announcement): add announcement management UI to config page Implement announcement settings panel with enable/disable toggle and content editor for admin users to manage announcements.
This commit is contained in:
@@ -177,6 +177,8 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
|
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
|
||||||
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
|
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
|
||||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||||
|
mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement)
|
||||||
|
mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement)
|
||||||
|
|
||||||
mux.HandleFunc("/flow/test", h.flowTest)
|
mux.HandleFunc("/flow/test", h.flowTest)
|
||||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||||
@@ -1228,3 +1230,53 @@ func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
result.AutoBackup = autoBackup
|
result.AutoBackup = autoBackup
|
||||||
response.WriteJSON(w, response.OK(result))
|
response.WriteJSON(w, response.OK(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ann, err := h.repo.GetAnnouncement()
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-1, fmt.Sprintf("获取公告失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ann == nil {
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"content": "",
|
||||||
|
"enabled": 0,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"content": ann.Content,
|
||||||
|
"enabled": ann.Enabled,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateAnnouncement(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if err := h.repo.UpsertAnnouncement(req.Content, req.Enabled, now); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-1, fmt.Sprintf("更新公告失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ func requiresAdmin(path string) bool {
|
|||||||
return true
|
return true
|
||||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||||
return true
|
return true
|
||||||
|
case "/api/v1/announcement/update":
|
||||||
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,3 +239,11 @@ CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
||||||
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS announcement (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_time BIGINT NOT NULL,
|
||||||
|
updated_time BIGINT
|
||||||
|
);
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ type ViteConfig struct {
|
|||||||
Time int64 `json:"time"`
|
Time int64 `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Announcement struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Enabled int `json:"enabled"`
|
||||||
|
CreatedTime int64 `json:"created_time"`
|
||||||
|
UpdatedTime sql.NullInt64 `json:"updated_time,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type UserTunnelDetail struct {
|
type UserTunnelDetail struct {
|
||||||
ID int64
|
ID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
@@ -319,6 +327,46 @@ func (r *Repository) UpsertConfig(name, value string, now int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetAnnouncement() (*Announcement, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
row := r.db.QueryRow(`SELECT id, content, enabled, created_time, updated_time FROM announcement ORDER BY id DESC LIMIT 1`)
|
||||||
|
ann := &Announcement{}
|
||||||
|
if err := row.Scan(&ann.ID, &ann.Content, &ann.Enabled, &ann.CreatedTime, &ann.UpdatedTime); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ann, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRow(`SELECT COUNT(*) FROM announcement`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
_, err = r.db.Exec(`
|
||||||
|
INSERT INTO announcement(content, enabled, created_time, updated_time)
|
||||||
|
VALUES(?, ?, ?, ?)
|
||||||
|
`, content, enabled, now, now)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.Exec(`
|
||||||
|
UPDATE announcement SET content = ?, enabled = ?, updated_time = ?
|
||||||
|
`, content, enabled, now)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) GetUserByID(id int64) (*User, error) {
|
func (r *Repository) GetUserByID(id int64) (*User, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
|
|||||||
@@ -244,3 +244,11 @@ CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
||||||
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS announcement (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_time INTEGER NOT NULL,
|
||||||
|
updated_time INTEGER
|
||||||
|
);
|
||||||
|
|||||||
@@ -285,3 +285,11 @@ export const exportBackup = async (types: string[] = []) => {
|
|||||||
|
|
||||||
export const importBackup = (data: { types: string[]; [key: string]: any }) =>
|
export const importBackup = (data: { types: string[]; [key: string]: any }) =>
|
||||||
Network.post("/backup/import", data);
|
Network.post("/backup/import", data);
|
||||||
|
|
||||||
|
export interface AnnouncementData {
|
||||||
|
content: string;
|
||||||
|
enabled: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAnnouncement = () => Network.get<AnnouncementData>("/announcement/get");
|
||||||
|
export const updateAnnouncement = (data: AnnouncementData) => Network.post("/announcement/update", data);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { Button } from "@heroui/button";
|
import { Button } from "@heroui/button";
|
||||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||||
import { Input } from "@heroui/input";
|
import { Input } from "@heroui/input";
|
||||||
|
import { Textarea } from "@heroui/input";
|
||||||
import { Spinner } from "@heroui/spinner";
|
import { Spinner } from "@heroui/spinner";
|
||||||
import { Divider } from "@heroui/divider";
|
import { Divider } from "@heroui/divider";
|
||||||
import { Switch } from "@heroui/switch";
|
import { Switch } from "@heroui/switch";
|
||||||
@@ -10,7 +11,7 @@ import { Select, SelectItem } from "@heroui/select";
|
|||||||
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
|
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { updateConfigs, exportBackup, importBackup } from "@/api";
|
import { updateConfigs, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData } from "@/api";
|
||||||
import { SettingsIcon } from "@/components/icons";
|
import { SettingsIcon } from "@/components/icons";
|
||||||
import { isAdmin } from "@/utils/auth";
|
import { isAdmin } from "@/utils/auth";
|
||||||
import {
|
import {
|
||||||
@@ -144,6 +145,13 @@ export default function ConfigPage() {
|
|||||||
const [importFileName, setImportFileName] = useState("");
|
const [importFileName, setImportFileName] = useState("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [announcement, setAnnouncement] = useState<AnnouncementData>({
|
||||||
|
content: "",
|
||||||
|
enabled: 0,
|
||||||
|
});
|
||||||
|
const [announcementLoading, setAnnouncementLoading] = useState(true);
|
||||||
|
const [announcementSaving, setAnnouncementSaving] = useState(false);
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin()) {
|
if (!isAdmin()) {
|
||||||
@@ -188,21 +196,51 @@ export default function ConfigPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 延迟加载,避免阻塞初始渲染
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
loadConfigs(initialConfigs);
|
loadConfigs(initialConfigs);
|
||||||
|
loadAnnouncement();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []); // 只在组件挂载时执行一次
|
}, []);
|
||||||
|
|
||||||
|
const loadAnnouncement = async () => {
|
||||||
|
setAnnouncementLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getAnnouncement();
|
||||||
|
|
||||||
|
if (res.code === 0 && res.data) {
|
||||||
|
setAnnouncement(res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load announcement:", error);
|
||||||
|
} finally {
|
||||||
|
setAnnouncementLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAnnouncement = async () => {
|
||||||
|
setAnnouncementSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await updateAnnouncement(announcement);
|
||||||
|
|
||||||
|
if (res.code === 0) {
|
||||||
|
toast.success("公告保存成功");
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "保存失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("保存公告失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setAnnouncementSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 处理配置项变更
|
|
||||||
const handleConfigChange = (key: string, value: string) => {
|
const handleConfigChange = (key: string, value: string) => {
|
||||||
const newConfigs = { ...configs, [key]: value };
|
const newConfigs = { ...configs, [key]: value };
|
||||||
|
|
||||||
setConfigs(newConfigs);
|
setConfigs(newConfigs);
|
||||||
|
|
||||||
// 检查是否有变更
|
|
||||||
const hasChangesNow =
|
const hasChangesNow =
|
||||||
Object.keys(newConfigs).some(
|
Object.keys(newConfigs).some(
|
||||||
(k) => newConfigs[k] !== originalConfigs[k],
|
(k) => newConfigs[k] !== originalConfigs[k],
|
||||||
@@ -479,7 +517,6 @@ export default function ConfigPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 操作提示 */}
|
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||||
<CardBody className="py-3">
|
<CardBody className="py-3">
|
||||||
@@ -493,6 +530,69 @@ export default function ConfigPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card className="mt-6 shadow-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">公告管理</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
设置首页显示的公告内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<CardBody className="space-y-4 pt-6">
|
||||||
|
{announcementLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Switch
|
||||||
|
isSelected={announcement.enabled === 1}
|
||||||
|
onValueChange={(checked) =>
|
||||||
|
setAnnouncement({ ...announcement, enabled: checked ? 1 : 0 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{announcement.enabled === 1 ? "已启用" : "已禁用"}
|
||||||
|
</span>
|
||||||
|
</Switch>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
启用后,公告将在首页顶部显示
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="公告内容"
|
||||||
|
placeholder="请输入公告内容"
|
||||||
|
value={announcement.content}
|
||||||
|
variant="bordered"
|
||||||
|
minRows={4}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAnnouncement({ ...announcement, content: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isLoading={announcementSaving}
|
||||||
|
startContent={<SaveIcon className="w-4 h-4" />}
|
||||||
|
onClick={saveAnnouncement}
|
||||||
|
>
|
||||||
|
保存公告
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 备份与恢复 */}
|
{/* 备份与恢复 */}
|
||||||
<Card className="mt-6 shadow-md">
|
<Card className="mt-6 shadow-md">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
import { getUserPackageInfo } from "@/api";
|
import { getUserPackageInfo, getAnnouncement, type AnnouncementData } from "@/api";
|
||||||
|
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
flow: number;
|
flow: number;
|
||||||
@@ -71,6 +71,7 @@ export default function DashboardPage() {
|
|||||||
const [forwardList, setForwardList] = useState<Forward[]>([]);
|
const [forwardList, setForwardList] = useState<Forward[]>([]);
|
||||||
const [statisticsFlows, setStatisticsFlows] = useState<StatisticsFlow[]>([]);
|
const [statisticsFlows, setStatisticsFlows] = useState<StatisticsFlow[]>([]);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null);
|
||||||
|
|
||||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||||
const [addressModalTitle, setAddressModalTitle] = useState("");
|
const [addressModalTitle, setAddressModalTitle] = useState("");
|
||||||
@@ -170,22 +171,33 @@ export default function DashboardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 重置状态并加载数据,防止页面切换时显示旧数据
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setUserInfo({} as UserInfo);
|
setUserInfo({} as UserInfo);
|
||||||
setUserTunnels([]);
|
setUserTunnels([]);
|
||||||
setForwardList([]);
|
setForwardList([]);
|
||||||
setStatisticsFlows([]);
|
setStatisticsFlows([]);
|
||||||
|
|
||||||
// 检查用户是否是管理员
|
|
||||||
const adminStatus = localStorage.getItem("admin");
|
const adminStatus = localStorage.getItem("admin");
|
||||||
|
|
||||||
setIsAdmin(adminStatus === "true");
|
setIsAdmin(adminStatus === "true");
|
||||||
|
|
||||||
loadPackageData();
|
loadPackageData();
|
||||||
|
loadAnnouncement();
|
||||||
localStorage.setItem("e", "/dashboard");
|
localStorage.setItem("e", "/dashboard");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadAnnouncement = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getAnnouncement();
|
||||||
|
|
||||||
|
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||||
|
setAnnouncement(res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load announcement:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadPackageData = async () => {
|
const loadPackageData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -703,7 +715,35 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 lg:px-6 py-2 lg:py-4">
|
<div className="px-3 lg:px-6 py-2 lg:py-4">
|
||||||
{/* 响应式统计卡片 */}
|
{announcement && announcement.content && (
|
||||||
|
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||||
|
<CardBody className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
||||||
|
公告
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
||||||
|
{announcement.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
||||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||||
<CardBody className="p-3 lg:p-4">
|
<CardBody className="p-3 lg:p-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user