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(() => {
|
useEffect(() => {
|
||||||
const fetchHotKeywords = async () => {
|
const fetchHotKeywords = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api_request.search.hotKeywords();
|
const res: any = await api_request.search.hotKeywords();
|
||||||
if (res.data?.code === 0) {
|
// axios 拦截器已剥掉一层:res 即响应体 {data, code},成功码 10000
|
||||||
setHotKeywords(res.data.data);
|
const body = res?.data?.code !== undefined ? res.data : res;
|
||||||
|
if (body?.code === 10000 && Array.isArray(body?.data)) {
|
||||||
|
setHotKeywords(body.data);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setHotKeywords(["React Hooks", "RESTful API", "TypeScript", "Docker部署", "GraphQL", "微服务架构", "CI/CD", "性能优化"]);
|
setHotKeywords(["React Hooks", "RESTful API", "TypeScript", "Docker部署", "GraphQL", "微服务架构", "CI/CD", "性能优化"]);
|
||||||
@@ -118,16 +120,18 @@ const Search: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const filterToUse = searchFilter !== undefined ? searchFilter : activeFilter;
|
const filterToUse = searchFilter !== undefined ? searchFilter : activeFilter;
|
||||||
const sortToUse = searchSort !== undefined ? searchSort : sort;
|
const sortToUse = searchSort !== undefined ? searchSort : sort;
|
||||||
const res = await api_request.search.global({
|
const res: any = await api_request.search.global({
|
||||||
q: q.trim(),
|
q: q.trim().slice(0, 100),
|
||||||
type: filterToUse === 'all' ? undefined : filterToUse,
|
type: filterToUse === 'all' ? undefined : filterToUse,
|
||||||
page: pageNum,
|
page: pageNum,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
sort: sortToUse,
|
sort: sortToUse,
|
||||||
});
|
});
|
||||||
if (res.data?.code === 0) {
|
// axios 拦截器已剥掉一层:res 即响应体 {data, code},成功码 10000
|
||||||
setResults(res.data.data.results || []);
|
const body = res?.data?.code !== undefined ? res.data : res;
|
||||||
setTotal(res.data.data.total || 0);
|
if (body?.code === 10000 && body?.data) {
|
||||||
|
setResults(body.data.results || []);
|
||||||
|
setTotal(body.data.total || 0);
|
||||||
setCurrentPage(pageNum);
|
setCurrentPage(pageNum);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -174,9 +178,11 @@ const Search: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api_request.search.suggestions({ q: keyword.trim(), limit: 8 });
|
const res: any = await api_request.search.suggestions({ q: keyword.trim(), limit: 8 });
|
||||||
if (res.data?.code === 0) {
|
// axios 拦截器已剥掉一层:res 即响应体 {data, code},成功码 10000
|
||||||
setSuggestions(res.data.data || []);
|
const body = res?.data?.code !== undefined ? res.data : res;
|
||||||
|
if (body?.code === 10000) {
|
||||||
|
setSuggestions(body?.data || []);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
@@ -225,19 +231,20 @@ const Search: React.FC = () => {
|
|||||||
|
|
||||||
const getResultUrl = (item: SearchResult) => {
|
const getResultUrl = (item: SearchResult) => {
|
||||||
if (item.url) return item.url;
|
if (item.url) return item.url;
|
||||||
if (item.type === '文章') return `/articles/${item.id}`;
|
if (item.type === '文章') return `/post/${item.id}`;
|
||||||
if (item.type === '工具') return `/tools/${item.id}`;
|
if (item.type === '工具') return '/utility';
|
||||||
if (item.type === '课程') return `/courses/${item.id}`;
|
if (item.type === '课程') return `/course-learn?id=${item.id}`;
|
||||||
|
if (item.type === 'API') return `/open-api-detail/${item.id}`;
|
||||||
return '/search';
|
return '/search';
|
||||||
};
|
};
|
||||||
|
|
||||||
const categories = [
|
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.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.typeCourse'), icon: <BookOutlined />, gradient: "linear-gradient(135deg, #4facfe, #00f2fe)", path: "/learn" },
|
||||||
{ name: t('search.typeTool'), icon: <ToolOutlined />, gradient: "linear-gradient(135deg, #43e97b, #38f9d7)", path: "/api" },
|
{ 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: "/frontend" },
|
{ 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: "/backend" },
|
{ name: t('search.typeBackend'), icon: <CloudOutlined />, gradient: "linear-gradient(135deg, #a18cd1, #fbc2eb)", path: "/learn" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const filterTabs = [
|
const filterTabs = [
|
||||||
|
|||||||
Reference in New Issue
Block a user