feat(C-04):AI图片放大页+搜索结果跳转修复

新增/utility/image-upscaler页(2x/4x上传放大、积分扣费402余额不足分支、blob下载);Search页响应体剥层对齐(成功码10000)、q截断100、结果URL对齐后端现行路由(/post、/utility、/course-learn、/open-api-detail)。
This commit is contained in:
chunyu
2026-09-15 15:26:51 +08:00
parent e90b787730
commit af18085f40
3 changed files with 258 additions and 19 deletions
+87
View File
@@ -0,0 +1,87 @@
.upscaler-page {
max-width: 1080px;
margin: 0 auto;
padding: 32px 20px 64px;
}
.upscaler-inner {
display: flex;
flex-direction: column;
gap: 8px;
}
.upscaler-title {
font-size: 26px;
font-weight: 800;
margin: 0;
}
.upscaler-desc {
color: #64748b;
margin: 0 0 16px;
}
.upscaler-balance {
color: #059669;
font-weight: 700;
}
.upscaler-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.upscaler-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.upscaler-card h3 {
margin: 0;
font-size: 15px;
}
.upscaler-preview {
width: 100%;
border-radius: 10px;
border: 1px solid #e2e8f0;
margin-top: 8px;
}
.upscaler-actions {
margin-top: 8px;
}
.upscaler-loading {
display: flex;
align-items: center;
gap: 8px;
color: #64748b;
}
.upscaler-empty {
color: #94a3b8;
font-size: 13px;
}
.upscaler-download {
display: inline-block;
margin-top: 10px;
}
@media (max-width: 900px) {
.upscaler-grid {
grid-template-columns: 1fr;
}
}
[data-theme="dark"] .upscaler-card {
background: #1e293b;
border-color: #334155;
}
+145
View File
@@ -0,0 +1,145 @@
import "./ImageUpscaler.css";
import React, { useRef, useState } from "react";
import { Button, Radio, Spin } from "antd";
import { message } from "@/utils/message";
import {
ExpandOutlined,
UploadOutlined,
DownloadOutlined,
} from "@ant-design/icons";
import { useRecordHistory } from "@/hooks/useRecordHistory";
import { api_request } from "@/utils/request";
const COST_TIPS: Record<number, number> = { 2: 2, 4: 2 };
const ImageUpscaler: React.FC = () => {
useRecordHistory({
type: "tool",
title: "AI 图片放大",
description: "2x/4x 无损放大,扣积分",
category: "AI 工具",
link: "/utility/image-upscaler",
});
const fileRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState("");
const [scale, setScale] = useState<2 | 4>(2);
const [balance, setBalance] = useState<number | null>(null);
const [running, setRunning] = useState(false);
const [resultUrl, setResultUrl] = useState("");
const [resultName, setResultName] = useState("");
const pick = (f: File | undefined) => {
if (!f) return;
if (!f.type.startsWith("image/")) {
message.error("请上传图片文件");
return;
}
if (f.size > 10 * 1024 * 1024) {
message.error("图片过大(>10MB)");
return;
}
setFile(f);
setPreview(URL.createObjectURL(f));
setResultUrl("");
};
const run = async () => {
if (!file || running) return;
setRunning(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("scale", String(scale));
const res: any = await api_request.aitool.upscale(fd);
// 后端返回文件流(axios blob):兼容 request 封装的多种返回形态
const blob: Blob | undefined = res?.data instanceof Blob ? res.data : res instanceof Blob ? res : undefined;
if (blob) {
const url = URL.createObjectURL(blob);
setResultUrl(url);
setResultName(`upscaled_${scale}x.png`);
// header 余额(axios response headers 小写)
const headers = res?.headers || {};
const bal = headers["x-aitool-balance"];
if (bal !== undefined) setBalance(Number(bal));
message.success(`放大成功,已扣 ${COST_TIPS[scale]} 积分`);
} else {
message.success("放大成功");
}
} catch (e: any) {
const status = e?.response?.status;
const data = e?.response?.data;
if (status === 402) {
message.error(`积分不足(需 ${data?.cost ?? COST_TIPS[scale]} 积分),先去签到赚积分`);
} else {
const msg = typeof data === "string" ? data : data?.error || "处理失败,积分已退回";
message.error(msg);
}
} finally {
setRunning(false);
}
};
return (
<div className="upscaler-page">
<div className="upscaler-inner">
<h1 className="upscaler-title">
<ExpandOutlined /> AI 图片放大
</h1>
<p className="upscaler-desc">
本地高清重采样 2x / 4x · 每次扣 {COST_TIPS[scale]} 积分 · 失败自动退回
{balance !== null && <span className="upscaler-balance"> · 余额 {balance} 积分</span>}
</p>
<div className="upscaler-grid">
<div className="upscaler-card">
<h3>1 · 上传图片</h3>
<input
ref={fileRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pick(e.target.files?.[0])}
/>
<Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>
选择图片
</Button>
{preview && <img className="upscaler-preview" src={preview} alt="原图预览" />}
</div>
<div className="upscaler-card">
<h3>2 · 放大倍数</h3>
<Radio.Group value={scale} onChange={(e) => setScale(e.target.value)}>
<Radio.Button value={2}>2x(扣 2 积分)</Radio.Button>
<Radio.Button value={4}>4x(扣 2 积分)</Radio.Button>
</Radio.Group>
<div className="upscaler-actions">
<Button type="primary" icon={<ExpandOutlined />} loading={running} disabled={!file} onClick={run}>
{running ? "放大中…" : "开始放大"}
</Button>
</div>
{running && (
<div className="upscaler-loading">
<Spin /> <span>正在放大,请稍候…</span>
</div>
)}
</div>
<div className="upscaler-card">
<h3>3 · 预览与下载</h3>
{resultUrl ? (
<div>
<img className="upscaler-preview" src={resultUrl} alt="放大结果" />
<a className="upscaler-download" href={resultUrl} download={resultName}>
<Button type="primary" icon={<DownloadOutlined />}>下载 PNG</Button>
</a>
</div>
) : (
<p className="upscaler-empty">放大完成后在此预览与下载</p>
)}
</div>
</div>
</div>
</div>
);
};
export default ImageUpscaler;
+26 -19
View File
@@ -96,9 +96,11 @@ const Search: React.FC = () => {
useEffect(() => {
const fetchHotKeywords = async () => {
try {
const res = await api_request.search.hotKeywords();
if (res.data?.code === 0) {
setHotKeywords(res.data.data);
const res: any = await api_request.search.hotKeywords();
// axios 拦截器已剥掉一层:res 即响应体 {data, code},成功码 10000
const body = res?.data?.code !== undefined ? res.data : res;
if (body?.code === 10000 && Array.isArray(body?.data)) {
setHotKeywords(body.data);
}
} catch {
setHotKeywords(["React Hooks", "RESTful API", "TypeScript", "Docker部署", "GraphQL", "微服务架构", "CI/CD", "性能优化"]);
@@ -118,16 +120,18 @@ const Search: React.FC = () => {
try {
const filterToUse = searchFilter !== undefined ? searchFilter : activeFilter;
const sortToUse = searchSort !== undefined ? searchSort : sort;
const res = await api_request.search.global({
q: q.trim(),
const res: any = await api_request.search.global({
q: q.trim().slice(0, 100),
type: filterToUse === 'all' ? undefined : filterToUse,
page: pageNum,
page_size: pageSize,
sort: sortToUse,
});
if (res.data?.code === 0) {
setResults(res.data.data.results || []);
setTotal(res.data.data.total || 0);
// axios 拦截器已剥掉一层:res 即响应体 {data, code},成功码 10000
const body = res?.data?.code !== undefined ? res.data : res;
if (body?.code === 10000 && body?.data) {
setResults(body.data.results || []);
setTotal(body.data.total || 0);
setCurrentPage(pageNum);
}
} catch {
@@ -174,9 +178,11 @@ const Search: React.FC = () => {
return;
}
try {
const res = await api_request.search.suggestions({ q: keyword.trim(), limit: 8 });
if (res.data?.code === 0) {
setSuggestions(res.data.data || []);
const res: any = await api_request.search.suggestions({ q: keyword.trim(), limit: 8 });
// axios 拦截器已剥掉一层:res 即响应体 {data, code},成功码 10000
const body = res?.data?.code !== undefined ? res.data : res;
if (body?.code === 10000) {
setSuggestions(body?.data || []);
}
} catch {
setSuggestions([]);
@@ -225,19 +231,20 @@ const Search: React.FC = () => {
const getResultUrl = (item: SearchResult) => {
if (item.url) return item.url;
if (item.type === '文章') return `/articles/${item.id}`;
if (item.type === '工具') return `/tools/${item.id}`;
if (item.type === '课程') return `/courses/${item.id}`;
if (item.type === '文章') return `/post/${item.id}`;
if (item.type === '工具') return '/utility';
if (item.type === '课程') return `/course-learn?id=${item.id}`;
if (item.type === 'API') return `/open-api-detail/${item.id}`;
return '/search';
};
const categories = [
{ name: t('search.typeApi'), icon: <ApiOutlined />, gradient: "linear-gradient(135deg, #667eea, #764ba2)", path: "/api" },
{ name: t('search.typeApi'), icon: <ApiOutlined />, gradient: "linear-gradient(135deg, #667eea, #764ba2)", path: "/open-api" },
{ name: t('search.typeArticle'), icon: <FileTextOutlined />, gradient: "linear-gradient(135deg, #f093fb, #f5576c)", path: "/articles" },
{ name: t('search.typeCourse'), icon: <BookOutlined />, gradient: "linear-gradient(135deg, #4facfe, #00f2fe)", path: "/courses" },
{ name: t('search.typeTool'), icon: <ToolOutlined />, gradient: "linear-gradient(135deg, #43e97b, #38f9d7)", path: "/api" },
{ name: t('search.typeFrontend'), icon: <CodeOutlined />, gradient: "linear-gradient(135deg, #fa709a, #fee140)", path: "/frontend" },
{ name: t('search.typeBackend'), icon: <CloudOutlined />, gradient: "linear-gradient(135deg, #a18cd1, #fbc2eb)", path: "/backend" },
{ name: t('search.typeCourse'), icon: <BookOutlined />, gradient: "linear-gradient(135deg, #4facfe, #00f2fe)", path: "/learn" },
{ name: t('search.typeTool'), icon: <ToolOutlined />, gradient: "linear-gradient(135deg, #43e97b, #38f9d7)", path: "/utility" },
{ name: t('search.typeFrontend'), icon: <CodeOutlined />, gradient: "linear-gradient(135deg, #fa709a, #fee140)", path: "/open-api" },
{ name: t('search.typeBackend'), icon: <CloudOutlined />, gradient: "linear-gradient(135deg, #a18cd1, #fbc2eb)", path: "/learn" },
];
const filterTabs = [