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:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user