新增班费图片上传 和 详情页。
This commit is contained in:
parent
7404498d46
commit
5659a15636
35
backend/alembic/versions/20260515_add_fund_record_images.py
Normal file
35
backend/alembic/versions/20260515_add_fund_record_images.py
Normal 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")
|
||||
@ -6,12 +6,31 @@ from app.db.database import get_db
|
||||
from app.db.models import User
|
||||
from app.schemas.user import UserPublic
|
||||
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
|
||||
|
||||
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])
|
||||
async def search_members(
|
||||
search: str | None = None,
|
||||
|
||||
@ -22,6 +22,7 @@ def record_to_out(record: FundRecord) -> FundRecordOut:
|
||||
amount=record.amount,
|
||||
category=record.category,
|
||||
description=record.description,
|
||||
image_urls=record.get_image_urls_list(),
|
||||
record_date=record.record_date,
|
||||
recorder_id=record.recorder_id,
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
async def create_new_record(
|
||||
data: FundRecordCreate,
|
||||
|
||||
@ -562,6 +562,7 @@ class FundRecord(Base):
|
||||
amount: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
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)
|
||||
recorder_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
@ -573,3 +574,14 @@ class FundRecord(Base):
|
||||
|
||||
class_: Mapped["Class_"] = relationship("Class_", back_populates="fund_records")
|
||||
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
|
||||
|
||||
@ -8,6 +8,7 @@ class FundRecordCreate(BaseModel):
|
||||
amount: float
|
||||
category: str
|
||||
description: str | None = None
|
||||
image_urls: list[str] | None = None
|
||||
record_date: date
|
||||
|
||||
|
||||
@ -16,6 +17,7 @@ class FundRecordUpdate(BaseModel):
|
||||
amount: float | None = None
|
||||
category: str | None = None
|
||||
description: str | None = None
|
||||
image_urls: list[str] | None = None
|
||||
record_date: date | None = None
|
||||
|
||||
|
||||
@ -26,6 +28,7 @@ class FundRecordOut(BaseModel):
|
||||
amount: float
|
||||
category: str
|
||||
description: str | None
|
||||
image_urls: list[str] | None
|
||||
record_date: date
|
||||
recorder_id: int
|
||||
recorder_name: str
|
||||
@ -43,4 +46,4 @@ class FundStatistics(BaseModel):
|
||||
total_expense: float
|
||||
balance: float
|
||||
income_by_category: list[CategoryAmount]
|
||||
expense_by_category: list[CategoryAmount]
|
||||
expense_by_category: list[CategoryAmount]
|
||||
|
||||
@ -49,6 +49,7 @@ class UserPublic(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
student_id: str | None
|
||||
membership_role: str | None = None
|
||||
industry: str | None
|
||||
company: str | None
|
||||
position: str | None
|
||||
|
||||
@ -78,6 +78,21 @@ async def search_directory(
|
||||
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(
|
||||
user: User, class_id: int | None = None, include_contact: bool = True
|
||||
) -> UserPublic:
|
||||
@ -87,6 +102,7 @@ def user_to_public(
|
||||
id=user.id,
|
||||
name=user.name,
|
||||
student_id=user.student_id,
|
||||
membership_role=membership.membership_role if membership else None,
|
||||
industry=user.industry,
|
||||
company=user.company,
|
||||
position=user.position,
|
||||
|
||||
@ -16,8 +16,10 @@ async def create_fund_record(
|
||||
amount=data.amount,
|
||||
category=data.category,
|
||||
description=data.description,
|
||||
image_urls=None,
|
||||
record_date=data.record_date,
|
||||
)
|
||||
record.set_image_urls_list(data.image_urls or [])
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
@ -27,8 +29,13 @@ async def create_fund_record(
|
||||
async def update_fund_record(
|
||||
db: AsyncSession, record: FundRecord, data: FundRecordUpdate
|
||||
) -> 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)
|
||||
if has_image_urls:
|
||||
record.set_image_urls_list(image_urls or [])
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record
|
||||
@ -124,4 +131,4 @@ async def get_fund_statistics(db: AsyncSession, class_id: int) -> FundStatistics
|
||||
balance=balance,
|
||||
income_by_category=income_by_category,
|
||||
expense_by_category=expense_by_category,
|
||||
)
|
||||
)
|
||||
|
||||
@ -68,12 +68,17 @@ export default function MemberDetailPage() {
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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 && (
|
||||
<Badge className="mt-1 bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]">
|
||||
{member.committee_role}
|
||||
</Badge>
|
||||
)}
|
||||
{member.student_id && (
|
||||
{member.membership_role !== "teacher" && member.student_id && (
|
||||
<p className="mt-1 text-sm text-[#896c5a]">学号: {member.student_id}</p>
|
||||
)}
|
||||
{member.company && (
|
||||
|
||||
@ -24,6 +24,7 @@ export default function DirectoryPage() {
|
||||
const { activeClassId } = useActiveClass();
|
||||
const [members, setMembers] = useState<UserPublic[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [roleCounts, setRoleCounts] = useState({ student_count: 0, teacher_count: 0, total: 0 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [industry, setIndustry] = useState("");
|
||||
const [company, setCompany] = useState("");
|
||||
@ -36,6 +37,7 @@ export default function DirectoryPage() {
|
||||
if (!activeClassId) {
|
||||
setMembers([]);
|
||||
setTotal(0);
|
||||
setRoleCounts({ student_count: 0, teacher_count: 0, total: 0 });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@ -47,8 +49,12 @@ export default function DirectoryPage() {
|
||||
if (industry) params.industry = industry;
|
||||
if (company) params.company = company;
|
||||
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 ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
setRoleCounts(stats);
|
||||
setTotalPages(res.total_pages ?? 1);
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, "加载失败"));
|
||||
@ -72,7 +78,9 @@ export default function DirectoryPage() {
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Directory</div>
|
||||
<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>
|
||||
|
||||
{/* Search & Filters */}
|
||||
@ -136,6 +144,11 @@ export default function DirectoryPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<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 && (
|
||||
<Badge className="text-xs bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]">
|
||||
{member.committee_role}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useActiveClass } from "@/hooks/use-active-class";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@ -28,6 +28,8 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { ErrorState } from "@/components/error-state";
|
||||
import { Pagination } from "@/components/pagination";
|
||||
import { toast } from "sonner";
|
||||
import { ImagePlus, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FundRecord, FundStatistics, PageResponse } from "@/lib/types";
|
||||
import { FUND_TYPES, FUND_INCOME_CATEGORIES, FUND_EXPENSE_CATEGORIES } from "@/lib/constants";
|
||||
import { hasClassPermission } from "@/lib/permissions";
|
||||
@ -57,7 +59,9 @@ export default function FundPage() {
|
||||
const [formCategory, setFormCategory] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formDate, setFormDate] = useState("");
|
||||
const [formImageUrls, setFormImageUrls] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [uploadingImages, setUploadingImages] = useState(false);
|
||||
|
||||
// Delete state
|
||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||
@ -124,6 +128,7 @@ export default function FundPage() {
|
||||
setFormCategory("");
|
||||
setFormDescription("");
|
||||
setFormDate(new Date().toISOString().slice(0, 10));
|
||||
setFormImageUrls([]);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
@ -139,9 +144,32 @@ export default function FundPage() {
|
||||
setFormCategory(record.category);
|
||||
setFormDescription(record.description || "");
|
||||
setFormDate(record.record_date);
|
||||
setFormImageUrls(record.image_urls || []);
|
||||
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 () => {
|
||||
if (!activeClassId) return;
|
||||
const amount = parseFloat(formAmount);
|
||||
@ -165,6 +193,7 @@ export default function FundPage() {
|
||||
amount,
|
||||
category: formCategory.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
image_urls: formImageUrls,
|
||||
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 () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
@ -325,45 +379,61 @@ export default function FundPage() {
|
||||
{typeFilter === "all" ? "暂无班费记录" : `暂无${FUND_TYPES[typeFilter as keyof typeof FUND_TYPES]}记录`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
{records.map((r) => (
|
||||
<Card key={r.id}>
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
className={r.type === "income"
|
||||
? "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)}
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Badge
|
||||
className={r.type === "income"
|
||||
? "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"
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
{FUND_TYPES[r.type as keyof typeof FUND_TYPES]}
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
@ -428,6 +498,32 @@ export default function FundPage() {
|
||||
rows={2}
|
||||
/>
|
||||
</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>
|
||||
<Label>日期</Label>
|
||||
<Input
|
||||
|
||||
@ -67,6 +67,7 @@ export interface UserPublic {
|
||||
id: number;
|
||||
name: string;
|
||||
student_id: string | null;
|
||||
membership_role: "teacher" | "student" | null;
|
||||
industry: string | null;
|
||||
company: string | null;
|
||||
position: string | null;
|
||||
@ -335,6 +336,7 @@ export interface FundRecord {
|
||||
amount: number;
|
||||
category: string;
|
||||
description: string | null;
|
||||
image_urls: string[] | null;
|
||||
record_date: string;
|
||||
recorder_id: number;
|
||||
recorder_name: string;
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"pages/member-detail/index",
|
||||
"pages/schedule-detail/index",
|
||||
"pages/vote-detail/index",
|
||||
"pages/fund-detail/index",
|
||||
"pages/timeline-detail/index",
|
||||
"pages/timeline-create/index",
|
||||
"pages/profile-edit/index",
|
||||
|
||||
@ -431,6 +431,37 @@ page {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -609,6 +640,13 @@ page {
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.fund-detail-link {
|
||||
margin-top: 14rpx;
|
||||
color: #8b5a36;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
picker {
|
||||
|
||||
66
miniprogram/pages/fund-detail/index.js
Normal file
66
miniprogram/pages/fund-detail/index.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
3
miniprogram/pages/fund-detail/index.json
Normal file
3
miniprogram/pages/fund-detail/index.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
73
miniprogram/pages/fund-detail/index.wxml
Normal file
73
miniprogram/pages/fund-detail/index.wxml
Normal 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>
|
||||
60
miniprogram/pages/fund-detail/index.wxss
Normal file
60
miniprogram/pages/fund-detail/index.wxss
Normal 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);
|
||||
}
|
||||
@ -30,11 +30,13 @@ function scheduleTypeText(type) {
|
||||
Page({
|
||||
data: {
|
||||
className: "HKU ICB",
|
||||
homeStatus: "班级信息已同步",
|
||||
announcements: [],
|
||||
schedules: [],
|
||||
votes: [],
|
||||
timelines: [],
|
||||
quickModules: [],
|
||||
focusItems: [],
|
||||
unreadCount: 0,
|
||||
loading: false
|
||||
},
|
||||
@ -102,6 +104,48 @@ Page({
|
||||
}
|
||||
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);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
@ -115,6 +159,20 @@ Page({
|
||||
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) {
|
||||
wx.navigateTo({ url: `/pages/schedule-detail/index?id=${event.currentTarget.dataset.id}` });
|
||||
},
|
||||
|
||||
@ -2,27 +2,28 @@
|
||||
<view class="hero">
|
||||
<view class="eyebrow">HKU ICB CLASSHUB</view>
|
||||
<view class="hero-title">{{className}}</view>
|
||||
<view class="hero-subtitle">把公告、排期、投票和班级互动放在一个安静清晰的移动入口。</view>
|
||||
<view class="hero-metrics">
|
||||
<view class="metric">
|
||||
<view class="metric-number">{{unreadCount}}</view>
|
||||
<view class="metric-label">未读通知</view>
|
||||
</view>
|
||||
<view class="metric">
|
||||
<view class="metric-number">{{schedules.length}}</view>
|
||||
<view class="metric-label">近期安排</view>
|
||||
</view>
|
||||
<view class="metric">
|
||||
<view class="metric-number">{{votes.length}}</view>
|
||||
<view class="metric-label">班级投票</view>
|
||||
<view class="hero-subtitle">今天需要关注的班级信息,都在下面按优先级整理好了。</view>
|
||||
<view class="home-status">{{homeStatus}}</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{focusItems.length}}" class="section">
|
||||
<view class="section-head">
|
||||
<view class="section-title">今日关注</view>
|
||||
</view>
|
||||
<view wx:for="{{focusItems}}" wx:key="type" class="focus-card" bindtap="openFocus" data-type="{{item.type}}" data-id="{{item.id}}">
|
||||
<view class="row-mark">{{item.mark}}</view>
|
||||
<view class="row-body">
|
||||
<view class="focus-label">{{item.label}}</view>
|
||||
<view class="card-title">{{item.title}}</view>
|
||||
<view class="muted">{{item.detail}}</view>
|
||||
</view>
|
||||
<view class="pill">{{item.badge}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{quickModules.length}}" class="section">
|
||||
<view class="section-head">
|
||||
<view class="section-title">常用入口</view>
|
||||
<view class="section-action">按班级开放</view>
|
||||
<view class="section-title">功能入口</view>
|
||||
</view>
|
||||
<view class="grid">
|
||||
<view wx:for="{{quickModules}}" wx:key="key" class="module-tile" bindtap="openModule" data-key="{{item.key}}">
|
||||
@ -33,56 +34,6 @@
|
||||
</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 class="section-head">
|
||||
<view class="section-title">班级动态</view>
|
||||
@ -99,7 +50,7 @@
|
||||
</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>
|
||||
</view>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const { post } = require("../../utils/api");
|
||||
const { post, uploadFile } = require("../../utils/api");
|
||||
const { getModule } = require("../../utils/modules");
|
||||
const { hasManagePermission } = require("../../utils/permissions");
|
||||
const { ensureModuleOpen, getActiveClassId, showError } = require("../../utils/page-helpers");
|
||||
@ -33,6 +33,8 @@ Page({
|
||||
fundIncomeCategories: ["班费收取", "活动赞助", "其他收入"],
|
||||
fundExpenseCategories: ["聚餐", "活动物资", "场地费", "交通费", "礼品", "其他支出"],
|
||||
fundCategories: ["聚餐", "活动物资", "场地费", "交通费", "礼品", "其他支出"],
|
||||
fundImageUrls: [],
|
||||
uploadingImages: false,
|
||||
loading: false
|
||||
},
|
||||
|
||||
@ -72,7 +74,8 @@ Page({
|
||||
fundExpenseClass: form.type === "expense" ? "active expense" : "",
|
||||
fundCategories: form.type === "income"
|
||||
? this.data.fundIncomeCategories
|
||||
: this.data.fundExpenseCategories
|
||||
: this.data.fundExpenseCategories,
|
||||
fundImageUrls: []
|
||||
});
|
||||
wx.setNavigationBarTitle({ title: `新增${module.title}` });
|
||||
},
|
||||
@ -148,6 +151,43 @@ Page({
|
||||
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() {
|
||||
const classId = getActiveClassId();
|
||||
const moduleKey = this.data.moduleKey;
|
||||
@ -215,6 +255,7 @@ Page({
|
||||
amount: Number(form.amount),
|
||||
category: form.category,
|
||||
description: form.description || null,
|
||||
image_urls: this.data.fundImageUrls,
|
||||
record_date: form.record_date
|
||||
});
|
||||
}
|
||||
|
||||
@ -106,6 +106,19 @@
|
||||
<view class="form-label">备注</view>
|
||||
<textarea class="form-textarea" value="{{form.description}}" data-field="description" bindinput="onInput" placeholder="补充说明,可不填" />
|
||||
</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 class="form-submit-bar">
|
||||
|
||||
@ -30,3 +30,46 @@
|
||||
.fund-type.expense {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -14,7 +14,16 @@ Page({
|
||||
this.setData({ loading: true });
|
||||
try {
|
||||
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) {
|
||||
showError(error, "加载资料失败");
|
||||
} finally {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<view class="page" wx:if="{{member}}">
|
||||
<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-subtitle">{{member.company || "公司未填写"}} · {{member.position || "职位未填写"}}</view>
|
||||
</view>
|
||||
@ -20,7 +20,7 @@
|
||||
<view class="row-mark">班</view>
|
||||
<view class="row-body">
|
||||
<view class="card-title">班级角色</view>
|
||||
<view class="muted">{{member.committee_role || "同学"}}</view>
|
||||
<view class="muted">{{member.class_role_text}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -50,6 +50,7 @@ Page({
|
||||
isFund: false,
|
||||
isVotes: false,
|
||||
fundStats: null,
|
||||
directoryStats: null,
|
||||
needsRefresh: false,
|
||||
loading: false
|
||||
},
|
||||
@ -101,6 +102,10 @@ Page({
|
||||
if (this.data.moduleKey === "fund") {
|
||||
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 rawItems = Array.isArray(res) ? res : res.items || [];
|
||||
const currentUser = getApp().globalData.user || {};
|
||||
@ -110,6 +115,9 @@ Page({
|
||||
...item,
|
||||
can_delete: this.data.moduleKey === "timeline" && item.author_id === currentUserId,
|
||||
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_month: item.start_time ? `${String(item.start_time).slice(5, 7)}月` : "",
|
||||
schedule_time_text: this.data.moduleKey === "schedule" ? formatScheduleTime(item) : "",
|
||||
@ -122,7 +130,8 @@ Page({
|
||||
committee_text: item.committee_role ? ` · ${item.committee_role}` : "",
|
||||
fund_type_text: item.type === "income" ? "收入" : "支出",
|
||||
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 ? {
|
||||
...stats,
|
||||
@ -130,7 +139,7 @@ Page({
|
||||
total_expense_text: formatAmount(stats.total_expense),
|
||||
balance_text: formatAmount(stats.balance)
|
||||
} : null;
|
||||
this.setData({ items, fundStats });
|
||||
this.setData({ items, fundStats, directoryStats });
|
||||
} catch (error) {
|
||||
if (error.message === "该功能当前未开放") {
|
||||
wx.redirectTo({
|
||||
@ -162,6 +171,10 @@ Page({
|
||||
}
|
||||
if (key === "votes") {
|
||||
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 });
|
||||
},
|
||||
|
||||
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) {
|
||||
const id = event.currentTarget.dataset.id;
|
||||
wx.showActionSheet({
|
||||
|
||||
@ -29,6 +29,9 @@
|
||||
<view wx:if="{{isFund}}" class="section-head">
|
||||
<view class="section-title">班费明细</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:if="{{isTimeline}}" class="feed-card">
|
||||
<view class="feed-head">
|
||||
@ -52,9 +55,13 @@
|
||||
<view wx:elif="{{isDirectory}}" class="member-row">
|
||||
<view class="avatar">{{item.initial}}</view>
|
||||
<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.industry || "行业未填写"}}{{item.committee_text}}</view>
|
||||
<view wx:if="{{item.show_student_id}}" class="muted">学号:{{item.student_id}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -94,6 +101,10 @@
|
||||
</view>
|
||||
<view class="muted">{{item.description || "无备注"}}</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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user