新增班费图片上传 和 详情页。

This commit is contained in:
aaron 2026-05-15 11:16:52 +08:00
parent 7404498d46
commit 5659a15636
28 changed files with 762 additions and 117 deletions

View File

@ -0,0 +1,35 @@
"""add fund record images
Revision ID: 20260515_add_fund_record_images
Revises: 20260507_add_wechat_identity
Create Date: 2026-05-15 09:00:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260515_add_fund_record_images"
down_revision = "20260507_add_wechat_identity"
branch_labels = None
depends_on = None
def _has_column(inspector: sa.engine.reflection.Inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "fund_records" in inspector.get_table_names() and not _has_column(inspector, "fund_records", "image_urls"):
op.add_column("fund_records", sa.Column("image_urls", sa.Text(), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "fund_records" in inspector.get_table_names() and _has_column(inspector, "fund_records", "image_urls"):
op.drop_column("fund_records", "image_urls")

View File

@ -6,12 +6,31 @@ from app.db.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.schemas.common import PageResponse from app.schemas.common import PageResponse
from app.services.directory_service import search_directory, user_to_public from app.services.directory_service import get_directory_role_counts, search_directory, user_to_public
from app.services.user_service import get_user_by_id from app.services.user_service import get_user_by_id
router = APIRouter(prefix="/api/directory", tags=["directory"]) router = APIRouter(prefix="/api/directory", tags=["directory"])
@router.get("/stats")
async def get_directory_stats(
class_id: int | None = None,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return {"student_count": 0, "teacher_count": 0, "total": 0}
ensure_class_access(user, effective_class_id)
await ensure_class_module_enabled(db, effective_class_id, "directory")
counts = await get_directory_role_counts(db, effective_class_id)
return {
"student_count": counts["student"],
"teacher_count": counts["teacher"],
"total": counts["total"],
}
@router.get("/", response_model=PageResponse[UserPublic]) @router.get("/", response_model=PageResponse[UserPublic])
async def search_members( async def search_members(
search: str | None = None, search: str | None = None,

View File

@ -22,6 +22,7 @@ def record_to_out(record: FundRecord) -> FundRecordOut:
amount=record.amount, amount=record.amount,
category=record.category, category=record.category,
description=record.description, description=record.description,
image_urls=record.get_image_urls_list(),
record_date=record.record_date, record_date=record.record_date,
recorder_id=record.recorder_id, recorder_id=record.recorder_id,
recorder_name=record.recorder.name if record.recorder else "Unknown", recorder_name=record.recorder.name if record.recorder else "Unknown",
@ -69,6 +70,20 @@ async def get_fund_records(
return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages)
@router.get("/{record_id}", response_model=FundRecordOut)
async def get_fund_record_detail(
record_id: int,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
record = await get_fund_record_by_id(db, record_id)
if record is None:
raise HTTPException(status_code=404, detail="Record not found")
ensure_class_access(user, record.class_id)
await ensure_class_module_enabled(db, record.class_id, "fund")
return record_to_out(record)
@router.post("/", response_model=FundRecordOut) @router.post("/", response_model=FundRecordOut)
async def create_new_record( async def create_new_record(
data: FundRecordCreate, data: FundRecordCreate,

View File

@ -562,6 +562,7 @@ class FundRecord(Base):
amount: Mapped[float] = mapped_column(Float, nullable=False) amount: Mapped[float] = mapped_column(Float, nullable=False)
category: Mapped[str] = mapped_column(String(100), nullable=False) category: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
image_urls: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
record_date: Mapped[datetime] = mapped_column(Date, nullable=False) record_date: Mapped[datetime] = mapped_column(Date, nullable=False)
recorder_id: Mapped[int] = mapped_column( recorder_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False Integer, ForeignKey("users.id"), nullable=False
@ -573,3 +574,14 @@ class FundRecord(Base):
class_: Mapped["Class_"] = relationship("Class_", back_populates="fund_records") class_: Mapped["Class_"] = relationship("Class_", back_populates="fund_records")
recorder: Mapped["User"] = relationship("User") recorder: Mapped["User"] = relationship("User")
def get_image_urls_list(self) -> list[str]:
if not self.image_urls:
return []
try:
return json.loads(self.image_urls)
except (json.JSONDecodeError, TypeError):
return []
def set_image_urls_list(self, urls: list[str]):
self.image_urls = json.dumps(urls) if urls else None

View File

@ -8,6 +8,7 @@ class FundRecordCreate(BaseModel):
amount: float amount: float
category: str category: str
description: str | None = None description: str | None = None
image_urls: list[str] | None = None
record_date: date record_date: date
@ -16,6 +17,7 @@ class FundRecordUpdate(BaseModel):
amount: float | None = None amount: float | None = None
category: str | None = None category: str | None = None
description: str | None = None description: str | None = None
image_urls: list[str] | None = None
record_date: date | None = None record_date: date | None = None
@ -26,6 +28,7 @@ class FundRecordOut(BaseModel):
amount: float amount: float
category: str category: str
description: str | None description: str | None
image_urls: list[str] | None
record_date: date record_date: date
recorder_id: int recorder_id: int
recorder_name: str recorder_name: str
@ -43,4 +46,4 @@ class FundStatistics(BaseModel):
total_expense: float total_expense: float
balance: float balance: float
income_by_category: list[CategoryAmount] income_by_category: list[CategoryAmount]
expense_by_category: list[CategoryAmount] expense_by_category: list[CategoryAmount]

View File

@ -49,6 +49,7 @@ class UserPublic(BaseModel):
id: int id: int
name: str name: str
student_id: str | None student_id: str | None
membership_role: str | None = None
industry: str | None industry: str | None
company: str | None company: str | None
position: str | None position: str | None

View File

@ -78,6 +78,21 @@ async def search_directory(
return users, total return users, total
async def get_directory_role_counts(db: AsyncSession, class_id: int) -> dict[str, int]:
result = await db.execute(
select(ClassMembership.membership_role, func.count(ClassMembership.id))
.join(User)
.where(ClassMembership.class_id == class_id, User.status == "approved")
.group_by(ClassMembership.membership_role)
)
counts = {"student": 0, "teacher": 0}
for role, count in result.all():
if role in counts:
counts[role] = count
counts["total"] = counts["student"] + counts["teacher"]
return counts
def user_to_public( def user_to_public(
user: User, class_id: int | None = None, include_contact: bool = True user: User, class_id: int | None = None, include_contact: bool = True
) -> UserPublic: ) -> UserPublic:
@ -87,6 +102,7 @@ def user_to_public(
id=user.id, id=user.id,
name=user.name, name=user.name,
student_id=user.student_id, student_id=user.student_id,
membership_role=membership.membership_role if membership else None,
industry=user.industry, industry=user.industry,
company=user.company, company=user.company,
position=user.position, position=user.position,

View File

@ -16,8 +16,10 @@ async def create_fund_record(
amount=data.amount, amount=data.amount,
category=data.category, category=data.category,
description=data.description, description=data.description,
image_urls=None,
record_date=data.record_date, record_date=data.record_date,
) )
record.set_image_urls_list(data.image_urls or [])
db.add(record) db.add(record)
await db.commit() await db.commit()
await db.refresh(record) await db.refresh(record)
@ -27,8 +29,13 @@ async def create_fund_record(
async def update_fund_record( async def update_fund_record(
db: AsyncSession, record: FundRecord, data: FundRecordUpdate db: AsyncSession, record: FundRecord, data: FundRecordUpdate
) -> FundRecord: ) -> FundRecord:
for field, value in data.model_dump(exclude_unset=True).items(): values = data.model_dump(exclude_unset=True)
has_image_urls = "image_urls" in values
image_urls = values.pop("image_urls", None)
for field, value in values.items():
setattr(record, field, value) setattr(record, field, value)
if has_image_urls:
record.set_image_urls_list(image_urls or [])
await db.commit() await db.commit()
await db.refresh(record) await db.refresh(record)
return record return record
@ -124,4 +131,4 @@ async def get_fund_statistics(db: AsyncSession, class_id: int) -> FundStatistics
balance=balance, balance=balance,
income_by_category=income_by_category, income_by_category=income_by_category,
expense_by_category=expense_by_category, expense_by_category=expense_by_category,
) )

View File

@ -68,12 +68,17 @@ export default function MemberDetailPage() {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<h1 className="text-3xl font-semibold text-[#4e1d1a]">{member.name}</h1> <h1 className="text-3xl font-semibold text-[#4e1d1a]">{member.name}</h1>
{member.membership_role === "teacher" && (
<Badge className="mt-1 bg-[#6f2030] text-white hover:bg-[#6f2030]">
</Badge>
)}
{member.committee_role && ( {member.committee_role && (
<Badge className="mt-1 bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]"> <Badge className="mt-1 bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]">
{member.committee_role} {member.committee_role}
</Badge> </Badge>
)} )}
{member.student_id && ( {member.membership_role !== "teacher" && member.student_id && (
<p className="mt-1 text-sm text-[#896c5a]">: {member.student_id}</p> <p className="mt-1 text-sm text-[#896c5a]">: {member.student_id}</p>
)} )}
{member.company && ( {member.company && (

View File

@ -24,6 +24,7 @@ export default function DirectoryPage() {
const { activeClassId } = useActiveClass(); const { activeClassId } = useActiveClass();
const [members, setMembers] = useState<UserPublic[]>([]); const [members, setMembers] = useState<UserPublic[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [roleCounts, setRoleCounts] = useState({ student_count: 0, teacher_count: 0, total: 0 });
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [industry, setIndustry] = useState(""); const [industry, setIndustry] = useState("");
const [company, setCompany] = useState(""); const [company, setCompany] = useState("");
@ -36,6 +37,7 @@ export default function DirectoryPage() {
if (!activeClassId) { if (!activeClassId) {
setMembers([]); setMembers([]);
setTotal(0); setTotal(0);
setRoleCounts({ student_count: 0, teacher_count: 0, total: 0 });
setLoading(false); setLoading(false);
return; return;
} }
@ -47,8 +49,12 @@ export default function DirectoryPage() {
if (industry) params.industry = industry; if (industry) params.industry = industry;
if (company) params.company = company; if (company) params.company = company;
const res = await fetchAPI<PageResponse<UserPublic>>("/api/directory/", params); const res = await fetchAPI<PageResponse<UserPublic>>("/api/directory/", params);
const stats = await fetchAPI<{ student_count: number; teacher_count: number; total: number }>("/api/directory/stats", {
class_id: String(activeClassId),
});
setMembers(res.items ?? []); setMembers(res.items ?? []);
setTotal(res.total ?? 0); setTotal(res.total ?? 0);
setRoleCounts(stats);
setTotalPages(res.total_pages ?? 1); setTotalPages(res.total_pages ?? 1);
} catch (err: unknown) { } catch (err: unknown) {
setError(getErrorMessage(err, "加载失败")); setError(getErrorMessage(err, "加载失败"));
@ -72,7 +78,9 @@ export default function DirectoryPage() {
<div> <div>
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Directory</div> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Directory</div>
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1> <h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]"> {total} </p> <p className="mt-2 text-[#765a4d]">
{roleCounts.student_count} {roleCounts.teacher_count} {total}
</p>
</div> </div>
{/* Search & Filters */} {/* Search & Filters */}
@ -136,6 +144,11 @@ export default function DirectoryPage() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<p className="font-medium text-[#4e1d1a]">{member.name}</p> <p className="font-medium text-[#4e1d1a]">{member.name}</p>
{member.membership_role === "teacher" && (
<Badge className="text-xs bg-[#6f2030] text-white hover:bg-[#6f2030]">
</Badge>
)}
{member.committee_role && ( {member.committee_role && (
<Badge className="text-xs bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]"> <Badge className="text-xs bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]">
{member.committee_role} {member.committee_role}

View File

@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; import { useActiveClass } from "@/hooks/use-active-class";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api"; import { fetchAPI, postAPI, putAPI, deleteAPI, uploadAPI, getErrorMessage } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -28,6 +28,8 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
import { ErrorState } from "@/components/error-state"; import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination"; import { Pagination } from "@/components/pagination";
import { toast } from "sonner"; import { toast } from "sonner";
import { ImagePlus, X } from "lucide-react";
import Image from "next/image";
import type { FundRecord, FundStatistics, PageResponse } from "@/lib/types"; import type { FundRecord, FundStatistics, PageResponse } from "@/lib/types";
import { FUND_TYPES, FUND_INCOME_CATEGORIES, FUND_EXPENSE_CATEGORIES } from "@/lib/constants"; import { FUND_TYPES, FUND_INCOME_CATEGORIES, FUND_EXPENSE_CATEGORIES } from "@/lib/constants";
import { hasClassPermission } from "@/lib/permissions"; import { hasClassPermission } from "@/lib/permissions";
@ -57,7 +59,9 @@ export default function FundPage() {
const [formCategory, setFormCategory] = useState(""); const [formCategory, setFormCategory] = useState("");
const [formDescription, setFormDescription] = useState(""); const [formDescription, setFormDescription] = useState("");
const [formDate, setFormDate] = useState(""); const [formDate, setFormDate] = useState("");
const [formImageUrls, setFormImageUrls] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [uploadingImages, setUploadingImages] = useState(false);
// Delete state // Delete state
const [deleteTarget, setDeleteTarget] = useState<number | null>(null); const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
@ -124,6 +128,7 @@ export default function FundPage() {
setFormCategory(""); setFormCategory("");
setFormDescription(""); setFormDescription("");
setFormDate(new Date().toISOString().slice(0, 10)); setFormDate(new Date().toISOString().slice(0, 10));
setFormImageUrls([]);
setEditingId(null); setEditingId(null);
}; };
@ -139,9 +144,32 @@ export default function FundPage() {
setFormCategory(record.category); setFormCategory(record.category);
setFormDescription(record.description || ""); setFormDescription(record.description || "");
setFormDate(record.record_date); setFormDate(record.record_date);
setFormImageUrls(record.image_urls || []);
setDialogOpen(true); setDialogOpen(true);
}; };
const uploadFundImages = async (files: FileList | File[]) => {
const next = [...formImageUrls];
const remainingSlots = Math.max(0, 6 - next.length);
const selectedFiles = Array.from(files).slice(0, remainingSlots);
if (selectedFiles.length === 0) {
toast.error("最多上传 6 张小票图片");
return;
}
setUploadingImages(true);
try {
for (const file of selectedFiles) {
const formData = new FormData();
formData.append("file", file);
const res = await uploadAPI<{ url: string }>("/api/upload/image", formData);
next.push(res.url);
}
setFormImageUrls(next.slice(0, 6));
} finally {
setUploadingImages(false);
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!activeClassId) return; if (!activeClassId) return;
const amount = parseFloat(formAmount); const amount = parseFloat(formAmount);
@ -165,6 +193,7 @@ export default function FundPage() {
amount, amount,
category: formCategory.trim(), category: formCategory.trim(),
description: formDescription.trim() || null, description: formDescription.trim() || null,
image_urls: formImageUrls,
record_date: formDate, record_date: formDate,
}; };
@ -186,6 +215,31 @@ export default function FundPage() {
} }
}; };
const handlePickImages = async () => {
if (formImageUrls.length >= 6) {
toast.error("最多上传 6 张小票图片");
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.onchange = async () => {
const files = input.files;
if (!files?.length) return;
try {
await uploadFundImages(files);
} catch (err: unknown) {
toast.error(getErrorMessage(err, "上传图片失败"));
}
};
input.click();
};
const removeFundImage = (url: string) => {
setFormImageUrls((current) => current.filter((item) => item !== url));
};
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
@ -325,45 +379,61 @@ export default function FundPage() {
{typeFilter === "all" ? "暂无班费记录" : `暂无${FUND_TYPES[typeFilter as keyof typeof FUND_TYPES]}记录`} {typeFilter === "all" ? "暂无班费记录" : `暂无${FUND_TYPES[typeFilter as keyof typeof FUND_TYPES]}记录`}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{records.map((r) => ( {records.map((r) => (
<Card key={r.id}> <Card key={r.id}>
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-start justify-between gap-3">
<Badge <div className="flex items-center gap-3 min-w-0">
className={r.type === "income" <Badge
? "bg-green-100 text-green-800 hover:bg-green-100 border-green-200" className={r.type === "income"
: "bg-red-100 text-red-800 hover:bg-red-100 border-red-200" ? "bg-green-100 text-green-800 hover:bg-green-100 border-green-200"
} : "bg-red-100 text-red-800 hover:bg-red-100 border-red-200"
> }
{FUND_TYPES[r.type as keyof typeof FUND_TYPES]}
</Badge>
<div>
<p className="font-medium">
<span className={r.type === "income" ? "text-green-600" : "text-red-600"}>
{r.type === "income" ? "+" : "-"}¥{r.amount.toFixed(2)}
</span>
<span className="ml-2 text-gray-600">{r.category}</span>
</p>
<p className="text-sm text-gray-500">
{r.record_date} · {r.recorder_name}
{r.description ? ` · ${r.description}` : ""}
</p>
</div>
</div>
{isAdmin && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(r.id)}
> >
{FUND_TYPES[r.type as keyof typeof FUND_TYPES]}
</Button> </Badge>
<div className="min-w-0">
<p className="font-medium truncate">
<span className={r.type === "income" ? "text-green-600" : "text-red-600"}>
{r.type === "income" ? "+" : "-"}¥{r.amount.toFixed(2)}
</span>
<span className="ml-2 text-gray-600">{r.category}</span>
</p>
<p className="text-sm text-gray-500">
{r.record_date} · {r.recorder_name}
</p>
</div>
</div>
{isAdmin && (
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(r.id)}
>
</Button>
</div>
)}
</div>
{r.description && <p className="text-sm text-gray-600 whitespace-pre-wrap">{r.description}</p>}
{r.image_urls && r.image_urls.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{r.image_urls.map((url) => (
<button
key={url}
type="button"
className="group relative overflow-hidden rounded-md border border-gray-200 bg-gray-50 aspect-square"
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
>
<Image src={url} alt="小票图片" fill unoptimized className="object-cover" />
</button>
))}
</div> </div>
)} )}
</CardContent> </CardContent>
@ -428,6 +498,32 @@ export default function FundPage() {
rows={2} rows={2}
/> />
</div> </div>
<div>
<div className="flex items-center justify-between">
<Label></Label>
<Button type="button" variant="outline" size="sm" onClick={handlePickImages} disabled={uploadingImages}>
<ImagePlus className="mr-2 h-4 w-4" />
{uploadingImages ? "上传中..." : "添加图片"}
</Button>
</div>
<p className="mt-1 text-xs text-gray-500"> 6 </p>
{formImageUrls.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{formImageUrls.map((url) => (
<div key={url} className="group relative overflow-hidden rounded-md border border-gray-200 bg-gray-50 aspect-square">
<Image src={url} alt="小票预览" fill unoptimized className="object-cover" />
<button
type="button"
className="absolute right-1 top-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white opacity-100"
onClick={() => removeFundImage(url)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
<div> <div>
<Label></Label> <Label></Label>
<Input <Input

View File

@ -67,6 +67,7 @@ export interface UserPublic {
id: number; id: number;
name: string; name: string;
student_id: string | null; student_id: string | null;
membership_role: "teacher" | "student" | null;
industry: string | null; industry: string | null;
company: string | null; company: string | null;
position: string | null; position: string | null;
@ -335,6 +336,7 @@ export interface FundRecord {
amount: number; amount: number;
category: string; category: string;
description: string | null; description: string | null;
image_urls: string[] | null;
record_date: string; record_date: string;
recorder_id: number; recorder_id: number;
recorder_name: string; recorder_name: string;

View File

@ -10,6 +10,7 @@
"pages/member-detail/index", "pages/member-detail/index",
"pages/schedule-detail/index", "pages/schedule-detail/index",
"pages/vote-detail/index", "pages/vote-detail/index",
"pages/fund-detail/index",
"pages/timeline-detail/index", "pages/timeline-detail/index",
"pages/timeline-create/index", "pages/timeline-create/index",
"pages/profile-edit/index", "pages/profile-edit/index",

View File

@ -431,6 +431,37 @@ page {
gap: 22rpx; gap: 22rpx;
} }
.directory-summary {
margin-bottom: 18rpx;
}
.member-title-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
}
.member-role-badge {
flex: none;
min-height: 34rpx;
padding: 0 12rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 760;
line-height: 34rpx;
}
.member-role-badge.teacher {
background: #6b1f2b;
color: #fff8ed;
}
.member-role-badge.student {
background: #f2e5d6;
color: #7a4b2b;
}
.vote-meta { .vote-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -609,6 +640,13 @@ page {
font-weight: 780; font-weight: 780;
} }
.fund-detail-link {
margin-top: 14rpx;
color: #8b5a36;
font-size: 24rpx;
font-weight: 700;
}
input, input,
textarea, textarea,
picker { picker {

View File

@ -0,0 +1,66 @@
const { get } = require("../../utils/api");
const { showError } = require("../../utils/page-helpers");
function formatAmount(value) {
return Number(value || 0).toFixed(2);
}
function formatDateTime(value) {
if (!value) return "";
return String(value).replace("T", " ").slice(0, 16);
}
function normalizeRecord(record) {
const isIncome = record.type === "income";
const imageUrls = Array.isArray(record.image_urls) ? record.image_urls : [];
return {
...record,
amount_text: formatAmount(record.amount),
signed_amount_text: `${isIncome ? "+" : "-"}¥${formatAmount(record.amount)}`,
type_text: isIncome ? "收入" : "支出",
type_class: isIncome ? "income" : "expense",
image_urls: imageUrls,
image_count_text: imageUrls.length ? `${imageUrls.length} 张凭证` : "未上传凭证",
created_at_text: formatDateTime(record.created_at),
updated_at_text: formatDateTime(record.updated_at)
};
}
Page({
data: {
id: null,
record: null,
loading: false
},
onLoad(options) {
wx.setNavigationBarTitle({ title: "班费详情" });
this.setData({ id: options.id || null });
this.load(options.id);
},
async onPullDownRefresh() {
await this.load(this.data.id);
wx.stopPullDownRefresh();
},
async load(id) {
if (!id) return;
this.setData({ loading: true });
try {
const record = await get(`/api/fund/${id}`);
this.setData({ record: normalizeRecord(record) });
} catch (error) {
showError(error, "加载班费详情失败");
} finally {
this.setData({ loading: false });
}
},
previewImage(event) {
const current = event.currentTarget.dataset.src;
const urls = this.data.record && this.data.record.image_urls ? this.data.record.image_urls : [];
if (!current || !urls.length) return;
wx.previewImage({ current, urls });
}
});

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,73 @@
<view class="page" wx:if="{{record}}">
<view class="hero fund-detail-hero {{record.type_class}}">
<view class="eyebrow">FUND LEDGER</view>
<view class="hero-title">{{record.signed_amount_text}}</view>
<view class="hero-subtitle">{{record.category}} · {{record.record_date}}</view>
<view class="fund-proof-pill">{{record.image_count_text}}</view>
</view>
<view class="section">
<view class="section-head">
<view class="section-title">账目摘要</view>
<view class="section-action">{{record.type_text}}</view>
</view>
<view class="card">
<view class="fund-detail-row">
<view class="row-mark">类</view>
<view class="row-body">
<view class="card-title">分类</view>
<view class="muted">{{record.category}}</view>
</view>
</view>
<view class="fund-detail-row">
<view class="row-mark">日</view>
<view class="row-body">
<view class="card-title">发生日期</view>
<view class="muted">{{record.record_date}}</view>
</view>
</view>
<view class="fund-detail-row">
<view class="row-mark">录</view>
<view class="row-body">
<view class="card-title">录入人</view>
<view class="muted">{{record.recorder_name}}</view>
</view>
</view>
<view class="fund-detail-row last">
<view class="row-mark">时</view>
<view class="row-body">
<view class="card-title">录入时间</view>
<view class="muted">{{record.created_at_text}}</view>
</view>
</view>
</view>
</view>
<view class="section">
<view class="section-head">
<view class="section-title">备注</view>
</view>
<view class="card">
<view class="fund-note">{{record.description || "暂无备注"}}</view>
</view>
</view>
<view class="section">
<view class="section-head">
<view class="section-title">小票凭证</view>
<view class="section-action">{{record.image_count_text}}</view>
</view>
<view wx:if="{{record.image_urls.length}}" class="fund-proof-grid">
<image wx:for="{{record.image_urls}}" wx:key="*this" src="{{item}}" mode="aspectFill" data-src="{{item}}" bindtap="previewImage" />
</view>
<view wx:else class="card">
<view class="muted">这条记录没有上传小票图片</view>
</view>
</view>
</view>
<view class="page" wx:elif="{{!loading}}">
<view class="empty">
<view class="muted">未找到班费记录</view>
</view>
</view>

View File

@ -0,0 +1,60 @@
.fund-detail-hero.income {
background: linear-gradient(145deg, #1f7a4d 0%, #8b5a36 72%, #d6a653 135%);
}
.fund-detail-hero.expense {
background: linear-gradient(145deg, #6b1f2b 0%, #9a3a2f 72%, #d6a653 135%);
}
.fund-proof-pill {
position: relative;
display: inline-flex;
align-items: center;
min-height: 48rpx;
margin-top: 28rpx;
padding: 0 22rpx;
border: 1rpx solid rgba(255, 248, 237, 0.18);
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 248, 237, 0.9);
font-size: 24rpx;
font-weight: 650;
}
.fund-detail-row {
display: flex;
align-items: center;
gap: 22rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid rgba(121, 84, 54, 0.1);
}
.fund-detail-row:first-child {
padding-top: 0;
}
.fund-detail-row.last {
padding-bottom: 0;
border-bottom: 0;
}
.fund-note {
color: #4f3930;
font-size: 27rpx;
line-height: 1.65;
white-space: pre-wrap;
}
.fund-proof-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14rpx;
}
.fund-proof-grid image {
width: 100%;
height: 300rpx;
border-radius: 24rpx;
background: #efe0ca;
box-shadow: 0 14rpx 32rpx rgba(68, 39, 27, 0.08);
}

View File

@ -30,11 +30,13 @@ function scheduleTypeText(type) {
Page({ Page({
data: { data: {
className: "HKU ICB", className: "HKU ICB",
homeStatus: "班级信息已同步",
announcements: [], announcements: [],
schedules: [], schedules: [],
votes: [], votes: [],
timelines: [], timelines: [],
quickModules: [], quickModules: [],
focusItems: [],
unreadCount: 0, unreadCount: 0,
loading: false loading: false
}, },
@ -102,6 +104,48 @@ Page({
} }
if (name === "timelines") next.timelines = value.items || []; if (name === "timelines") next.timelines = value.items || [];
}); });
const pendingVotes = next.votes.filter((item) => !item.has_voted);
const focusItems = [];
if (next.schedules.length) {
const schedule = next.schedules[0];
focusItems.push({
id: schedule.id,
type: "schedule",
mark: "日",
label: "下一项排期",
title: schedule.title,
detail: schedule.schedule_time_text,
badge: schedule.schedule_type_text
});
}
if (pendingVotes.length) {
const vote = pendingVotes[0];
focusItems.push({
id: vote.id,
type: "vote",
mark: "选",
label: "待参与投票",
title: vote.title,
detail: `${vote.vote_type_text} · ${vote.total_voters} 人参与`,
badge: "去参与"
});
}
if (next.announcements.length) {
const announcement = next.announcements[0];
focusItems.push({
id: announcement.id,
type: "announcements",
mark: "告",
label: "最新公告",
title: announcement.title,
detail: announcement.author_name || "班级公告",
badge: "查看"
});
}
next.focusItems = focusItems.slice(0, 3);
next.homeStatus = next.unreadCount > 0
? `${next.unreadCount} 条未读通知`
: "暂无未读通知";
this.setData(next); this.setData(next);
} catch (error) { } catch (error) {
showError(error); showError(error);
@ -115,6 +159,20 @@ Page({
wx.navigateTo({ url: `/pages/module/index?module=${key}` }); wx.navigateTo({ url: `/pages/module/index?module=${key}` });
}, },
openFocus(event) {
const type = event.currentTarget.dataset.type;
const id = event.currentTarget.dataset.id;
if (type === "schedule") {
wx.navigateTo({ url: `/pages/schedule-detail/index?id=${id}` });
return;
}
if (type === "vote") {
wx.navigateTo({ url: `/pages/vote-detail/index?id=${id}` });
return;
}
wx.navigateTo({ url: `/pages/module/index?module=${type}` });
},
openSchedule(event) { openSchedule(event) {
wx.navigateTo({ url: `/pages/schedule-detail/index?id=${event.currentTarget.dataset.id}` }); wx.navigateTo({ url: `/pages/schedule-detail/index?id=${event.currentTarget.dataset.id}` });
}, },

View File

@ -2,27 +2,28 @@
<view class="hero"> <view class="hero">
<view class="eyebrow">HKU ICB CLASSHUB</view> <view class="eyebrow">HKU ICB CLASSHUB</view>
<view class="hero-title">{{className}}</view> <view class="hero-title">{{className}}</view>
<view class="hero-subtitle">把公告、排期、投票和班级互动放在一个安静清晰的移动入口。</view> <view class="hero-subtitle">今天需要关注的班级信息,都在下面按优先级整理好了。</view>
<view class="hero-metrics"> <view class="home-status">{{homeStatus}}</view>
<view class="metric"> </view>
<view class="metric-number">{{unreadCount}}</view>
<view class="metric-label">未读通知</view> <view wx:if="{{focusItems.length}}" class="section">
</view> <view class="section-head">
<view class="metric"> <view class="section-title">今日关注</view>
<view class="metric-number">{{schedules.length}}</view> </view>
<view class="metric-label">近期安排</view> <view wx:for="{{focusItems}}" wx:key="type" class="focus-card" bindtap="openFocus" data-type="{{item.type}}" data-id="{{item.id}}">
</view> <view class="row-mark">{{item.mark}}</view>
<view class="metric"> <view class="row-body">
<view class="metric-number">{{votes.length}}</view> <view class="focus-label">{{item.label}}</view>
<view class="metric-label">班级投票</view> <view class="card-title">{{item.title}}</view>
<view class="muted">{{item.detail}}</view>
</view> </view>
<view class="pill">{{item.badge}}</view>
</view> </view>
</view> </view>
<view wx:if="{{quickModules.length}}" class="section"> <view wx:if="{{quickModules.length}}" class="section">
<view class="section-head"> <view class="section-head">
<view class="section-title">常用入口</view> <view class="section-title">功能入口</view>
<view class="section-action">按班级开放</view>
</view> </view>
<view class="grid"> <view class="grid">
<view wx:for="{{quickModules}}" wx:key="key" class="module-tile" bindtap="openModule" data-key="{{item.key}}"> <view wx:for="{{quickModules}}" wx:key="key" class="module-tile" bindtap="openModule" data-key="{{item.key}}">
@ -33,56 +34,6 @@
</view> </view>
</view> </view>
<view wx:if="{{announcements.length}}" class="section">
<view class="section-head">
<view class="section-title">最新公告</view>
<view class="section-action" bindtap="openModule" data-key="announcements">全部</view>
</view>
<view wx:for="{{announcements}}" wx:key="id" class="card" bindtap="openModule" data-key="announcements">
<view class="list-row">
<view class="row-mark">告</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.author_name}}</view>
</view>
</view>
</view>
</view>
<view wx:if="{{schedules.length}}" class="section">
<view class="section-head">
<view class="section-title">近期排期</view>
<view class="section-action" bindtap="openModule" data-key="schedule">日程</view>
</view>
<view wx:for="{{schedules}}" wx:key="id" class="card" bindtap="openSchedule" data-id="{{item.id}}">
<view class="list-row">
<view class="row-mark">日</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.location || "地点待定"}}</view>
<view class="muted">{{item.schedule_time_text}}</view>
</view>
<view class="pill">{{item.schedule_type_text}}</view>
</view>
</view>
</view>
<view wx:if="{{votes.length}}" class="section">
<view class="section-head">
<view class="section-title">班级投票</view>
<view class="section-action" bindtap="openModule" data-key="votes">参与</view>
</view>
<view wx:for="{{votes}}" wx:key="id" class="card" bindtap="openVote" data-id="{{item.id}}">
<view class="list-row">
<view class="row-mark">选</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.vote_type_text}} · {{item.vote_action_text}} · {{item.total_voters}} 人参与</view>
</view>
</view>
</view>
</view>
<view wx:if="{{timelines.length}}" class="section"> <view wx:if="{{timelines.length}}" class="section">
<view class="section-head"> <view class="section-head">
<view class="section-title">班级动态</view> <view class="section-title">班级动态</view>
@ -99,7 +50,7 @@
</view> </view>
</view> </view>
<view wx:if="{{!loading && !announcements.length && !schedules.length && !votes.length && !timelines.length}}" class="empty"> <view wx:if="{{!loading && !focusItems.length && !quickModules.length && !timelines.length}}" class="empty">
<view class="muted">暂无可展示内容</view> <view class="muted">暂无可展示内容</view>
</view> </view>
</view> </view>

View File

@ -1 +1,33 @@
.home-status {
position: relative;
display: inline-flex;
align-items: center;
min-height: 48rpx;
margin-top: 28rpx;
padding: 0 22rpx;
border: 1rpx solid rgba(255, 248, 237, 0.18);
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 248, 237, 0.9);
font-size: 24rpx;
font-weight: 650;
}
.focus-card {
display: flex;
align-items: center;
gap: 22rpx;
margin-bottom: 18rpx;
border: 1rpx solid rgba(121, 84, 54, 0.12);
border-radius: 30rpx;
background: linear-gradient(180deg, #fffdf8 0%, #fff7ed 100%);
padding: 26rpx;
box-shadow: 0 18rpx 42rpx rgba(68, 39, 27, 0.07);
}
.focus-label {
margin-bottom: 6rpx;
color: #8b5a36;
font-size: 22rpx;
font-weight: 760;
}

View File

@ -1,4 +1,4 @@
const { post } = require("../../utils/api"); const { post, uploadFile } = require("../../utils/api");
const { getModule } = require("../../utils/modules"); const { getModule } = require("../../utils/modules");
const { hasManagePermission } = require("../../utils/permissions"); const { hasManagePermission } = require("../../utils/permissions");
const { ensureModuleOpen, getActiveClassId, showError } = require("../../utils/page-helpers"); const { ensureModuleOpen, getActiveClassId, showError } = require("../../utils/page-helpers");
@ -33,6 +33,8 @@ Page({
fundIncomeCategories: ["班费收取", "活动赞助", "其他收入"], fundIncomeCategories: ["班费收取", "活动赞助", "其他收入"],
fundExpenseCategories: ["聚餐", "活动物资", "场地费", "交通费", "礼品", "其他支出"], fundExpenseCategories: ["聚餐", "活动物资", "场地费", "交通费", "礼品", "其他支出"],
fundCategories: ["聚餐", "活动物资", "场地费", "交通费", "礼品", "其他支出"], fundCategories: ["聚餐", "活动物资", "场地费", "交通费", "礼品", "其他支出"],
fundImageUrls: [],
uploadingImages: false,
loading: false loading: false
}, },
@ -72,7 +74,8 @@ Page({
fundExpenseClass: form.type === "expense" ? "active expense" : "", fundExpenseClass: form.type === "expense" ? "active expense" : "",
fundCategories: form.type === "income" fundCategories: form.type === "income"
? this.data.fundIncomeCategories ? this.data.fundIncomeCategories
: this.data.fundExpenseCategories : this.data.fundExpenseCategories,
fundImageUrls: []
}); });
wx.setNavigationBarTitle({ title: `新增${module.title}` }); wx.setNavigationBarTitle({ title: `新增${module.title}` });
}, },
@ -148,6 +151,43 @@ Page({
this.setData({ "form.category": this.data.fundCategories[index] }); this.setData({ "form.category": this.data.fundCategories[index] });
}, },
chooseFundImages() {
if (this.data.uploadingImages) return;
if (this.data.fundImageUrls.length >= 6) {
wx.showToast({ title: "最多 6 张图片", icon: "none" });
return;
}
wx.chooseMedia({
count: 6 - this.data.fundImageUrls.length,
mediaType: ["image"],
sourceType: ["album", "camera"],
success: async (res) => {
const paths = (res.tempFiles || []).map((item) => item.tempFilePath);
if (!paths.length) return;
this.setData({ uploadingImages: true });
try {
const uploaded = [...this.data.fundImageUrls];
for (const path of paths) {
const result = await uploadFile("/api/upload/image", path, {}, "file");
if (result && result.url) uploaded.push(result.url);
}
this.setData({ fundImageUrls: uploaded.slice(0, 6) });
} catch (error) {
showError(error, "上传图片失败");
} finally {
this.setData({ uploadingImages: false });
}
}
});
},
removeFundImage(event) {
const index = Number(event.currentTarget.dataset.index);
this.setData({
fundImageUrls: this.data.fundImageUrls.filter((_, itemIndex) => itemIndex !== index)
});
},
async submit() { async submit() {
const classId = getActiveClassId(); const classId = getActiveClassId();
const moduleKey = this.data.moduleKey; const moduleKey = this.data.moduleKey;
@ -215,6 +255,7 @@ Page({
amount: Number(form.amount), amount: Number(form.amount),
category: form.category, category: form.category,
description: form.description || null, description: form.description || null,
image_urls: this.data.fundImageUrls,
record_date: form.record_date record_date: form.record_date
}); });
} }

View File

@ -106,6 +106,19 @@
<view class="form-label">备注</view> <view class="form-label">备注</view>
<textarea class="form-textarea" value="{{form.description}}" data-field="description" bindinput="onInput" placeholder="补充说明,可不填" /> <textarea class="form-textarea" value="{{form.description}}" data-field="description" bindinput="onInput" placeholder="补充说明,可不填" />
</view> </view>
<view class="form-field">
<view class="form-label-row">
<view class="form-label">小票图片</view>
<view class="section-action" bindtap="chooseFundImages">{{uploadingImages ? "上传中..." : "添加图片"}}</view>
</view>
<view class="form-hint">最多 6 张,可拍照或从相册选择。</view>
<view wx:if="{{fundImageUrls.length}}" class="image-grid fund-image-grid">
<view wx:for="{{fundImageUrls}}" wx:key="*this" class="image-cell">
<image src="{{item}}" mode="aspectFill" />
<view class="image-remove" data-index="{{index}}" bindtap="removeFundImage">×</view>
</view>
</view>
</view>
</view> </view>
<view class="form-submit-bar"> <view class="form-submit-bar">

View File

@ -30,3 +30,46 @@
.fund-type.expense { .fund-type.expense {
color: #9a3a2f; color: #9a3a2f;
} }
.form-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.fund-image-grid {
margin-top: 16rpx;
}
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
}
.image-cell {
position: relative;
overflow: hidden;
height: 180rpx;
border-radius: 18rpx;
background: #efe0ca;
}
.image-cell image {
width: 100%;
height: 100%;
}
.image-remove {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 40rpx;
height: 40rpx;
border-radius: 999rpx;
background: rgba(47, 33, 28, 0.72);
color: #fff;
font-size: 32rpx;
line-height: 36rpx;
text-align: center;
}

View File

@ -14,7 +14,16 @@ Page({
this.setData({ loading: true }); this.setData({ loading: true });
try { try {
const member = await get(`/api/directory/${id}`); const member = await get(`/api/directory/${id}`);
this.setData({ member }); this.setData({
member: {
...member,
role_text: member.membership_role === "teacher" ? "老师" : "同学",
role_mark: member.membership_role === "teacher" ? "师" : "同",
class_role_text: member.membership_role === "teacher"
? "老师"
: member.committee_role || "同学"
}
});
} catch (error) { } catch (error) {
showError(error, "加载资料失败"); showError(error, "加载资料失败");
} finally { } finally {

View File

@ -1,6 +1,6 @@
<view class="page" wx:if="{{member}}"> <view class="page" wx:if="{{member}}">
<view class="hero"> <view class="hero">
<view class="eyebrow">CLASSMATE</view> <view class="eyebrow">{{member.role_text}}</view>
<view class="hero-title">{{member.name}}</view> <view class="hero-title">{{member.name}}</view>
<view class="hero-subtitle">{{member.company || "公司未填写"}} · {{member.position || "职位未填写"}}</view> <view class="hero-subtitle">{{member.company || "公司未填写"}} · {{member.position || "职位未填写"}}</view>
</view> </view>
@ -20,7 +20,7 @@
<view class="row-mark">班</view> <view class="row-mark">班</view>
<view class="row-body"> <view class="row-body">
<view class="card-title">班级角色</view> <view class="card-title">班级角色</view>
<view class="muted">{{member.committee_role || "同学"}}</view> <view class="muted">{{member.class_role_text}}</view>
</view> </view>
</view> </view>
</view> </view>

View File

@ -50,6 +50,7 @@ Page({
isFund: false, isFund: false,
isVotes: false, isVotes: false,
fundStats: null, fundStats: null,
directoryStats: null,
needsRefresh: false, needsRefresh: false,
loading: false loading: false
}, },
@ -101,6 +102,10 @@ Page({
if (this.data.moduleKey === "fund") { if (this.data.moduleKey === "fund") {
stats = await get("/api/fund/statistics", { class_id: classId }); stats = await get("/api/fund/statistics", { class_id: classId });
} }
let directoryStats = null;
if (this.data.moduleKey === "directory") {
directoryStats = await get("/api/directory/stats", { class_id: classId });
}
const res = await get(endpoint, { page_size: 20, class_id: classId }); const res = await get(endpoint, { page_size: 20, class_id: classId });
const rawItems = Array.isArray(res) ? res : res.items || []; const rawItems = Array.isArray(res) ? res : res.items || [];
const currentUser = getApp().globalData.user || {}; const currentUser = getApp().globalData.user || {};
@ -110,6 +115,9 @@ Page({
...item, ...item,
can_delete: this.data.moduleKey === "timeline" && item.author_id === currentUserId, can_delete: this.data.moduleKey === "timeline" && item.author_id === currentUserId,
initial: String(item.name || item.author_name || this.data.title || "项").slice(0, 1), initial: String(item.name || item.author_name || this.data.title || "项").slice(0, 1),
member_role_text: item.membership_role === "teacher" ? "老师" : "同学",
member_role_class: item.membership_role === "teacher" ? "teacher" : "student",
show_student_id: item.membership_role !== "teacher" && item.student_id,
schedule_day: item.start_time ? String(item.start_time).slice(8, 10) : "", schedule_day: item.start_time ? String(item.start_time).slice(8, 10) : "",
schedule_month: item.start_time ? `${String(item.start_time).slice(5, 7)}` : "", schedule_month: item.start_time ? `${String(item.start_time).slice(5, 7)}` : "",
schedule_time_text: this.data.moduleKey === "schedule" ? formatScheduleTime(item) : "", schedule_time_text: this.data.moduleKey === "schedule" ? formatScheduleTime(item) : "",
@ -122,7 +130,8 @@ Page({
committee_text: item.committee_role ? ` · ${item.committee_role}` : "", committee_text: item.committee_role ? ` · ${item.committee_role}` : "",
fund_type_text: item.type === "income" ? "收入" : "支出", fund_type_text: item.type === "income" ? "收入" : "支出",
fund_type_class: item.type === "income" ? "income" : "expense", fund_type_class: item.type === "income" ? "income" : "expense",
amount_text: formatAmount(item.amount) amount_text: formatAmount(item.amount),
image_urls: Array.isArray(item.image_urls) ? item.image_urls : []
})); }));
const fundStats = stats ? { const fundStats = stats ? {
...stats, ...stats,
@ -130,7 +139,7 @@ Page({
total_expense_text: formatAmount(stats.total_expense), total_expense_text: formatAmount(stats.total_expense),
balance_text: formatAmount(stats.balance) balance_text: formatAmount(stats.balance)
} : null; } : null;
this.setData({ items, fundStats }); this.setData({ items, fundStats, directoryStats });
} catch (error) { } catch (error) {
if (error.message === "该功能当前未开放") { if (error.message === "该功能当前未开放") {
wx.redirectTo({ wx.redirectTo({
@ -162,6 +171,10 @@ Page({
} }
if (key === "votes") { if (key === "votes") {
wx.navigateTo({ url: `/pages/vote-detail/index?id=${id}` }); wx.navigateTo({ url: `/pages/vote-detail/index?id=${id}` });
return;
}
if (key === "fund") {
wx.navigateTo({ url: `/pages/fund-detail/index?id=${id}` });
} }
}, },
@ -174,6 +187,15 @@ Page({
wx.previewImage({ current, urls }); wx.previewImage({ current, urls });
}, },
previewFundImage(event) {
const current = event.currentTarget.dataset.src;
const recordId = Number(event.currentTarget.dataset.recordId);
const record = this.data.items.find((item) => item.id === recordId);
const urls = record && record.image_urls ? record.image_urls : [];
if (!current || !urls.length) return;
wx.previewImage({ current, urls });
},
openTimelineActions(event) { openTimelineActions(event) {
const id = event.currentTarget.dataset.id; const id = event.currentTarget.dataset.id;
wx.showActionSheet({ wx.showActionSheet({

View File

@ -29,6 +29,9 @@
<view wx:if="{{isFund}}" class="section-head"> <view wx:if="{{isFund}}" class="section-head">
<view class="section-title">班费明细</view> <view class="section-title">班费明细</view>
</view> </view>
<view wx:if="{{isDirectory && directoryStats}}" class="section-head directory-summary">
<view class="section-title">同学 {{directoryStats.student_count}} 人 · 老师 {{directoryStats.teacher_count}} 人</view>
</view>
<view wx:for="{{items}}" wx:key="id" class="card" data-id="{{item.id || item.user_id}}" bindtap="openItem"> <view wx:for="{{items}}" wx:key="id" class="card" data-id="{{item.id || item.user_id}}" bindtap="openItem">
<view wx:if="{{isTimeline}}" class="feed-card"> <view wx:if="{{isTimeline}}" class="feed-card">
<view class="feed-head"> <view class="feed-head">
@ -52,9 +55,13 @@
<view wx:elif="{{isDirectory}}" class="member-row"> <view wx:elif="{{isDirectory}}" class="member-row">
<view class="avatar">{{item.initial}}</view> <view class="avatar">{{item.initial}}</view>
<view class="row-body"> <view class="row-body">
<view class="card-title">{{item.name}}</view> <view class="member-title-line">
<view class="card-title">{{item.name}}</view>
<view class="member-role-badge {{item.member_role_class}}">{{item.member_role_text}}</view>
</view>
<view class="muted">{{item.company || "公司未填写"}} · {{item.position || "职位未填写"}}</view> <view class="muted">{{item.company || "公司未填写"}} · {{item.position || "职位未填写"}}</view>
<view class="muted">{{item.industry || "行业未填写"}}{{item.committee_text}}</view> <view class="muted">{{item.industry || "行业未填写"}}{{item.committee_text}}</view>
<view wx:if="{{item.show_student_id}}" class="muted">学号:{{item.student_id}}</view>
</view> </view>
</view> </view>
@ -94,6 +101,10 @@
</view> </view>
<view class="muted">{{item.description || "无备注"}}</view> <view class="muted">{{item.description || "无备注"}}</view>
<view class="muted">{{item.recorder_name}} · {{item.record_date}}</view> <view class="muted">{{item.recorder_name}} · {{item.record_date}}</view>
<view wx:if="{{item.image_urls && item.image_urls.length}}" class="feed-images fund-images">
<image wx:for="{{item.image_urls}}" wx:for-item="img" wx:key="*this" src="{{img}}" mode="aspectFill" data-src="{{img}}" data-record-id="{{item.id}}" catchtap="previewFundImage" />
</view>
<view class="fund-detail-link">查看详情</view>
</view> </view>
</view> </view>