添加小白卡写入giffgaff分享

This commit is contained in:
wxf
2026-06-26 15:53:12 +08:00
parent d35a97f325
commit 90eb1de06d
18 changed files with 391 additions and 18 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+2 -2
View File
@@ -34,8 +34,8 @@ body {
}
::selection {
background: #22d3ee;
color: #05050b;
background: #b9c6a3;
color: #151515;
}
a {
+89 -7
View File
@@ -14,6 +14,65 @@ type LifeArticlePageProps = {
nextPost?: LifePost;
};
const paragraphLinkPattern = /\[([^\]]+)\]\(([^)\s]+)\)/g;
function isAllowedParagraphHref(href: string) {
return (
href.startsWith("/") ||
href.startsWith("#") ||
href.startsWith("https://") ||
href.startsWith("http://") ||
href.startsWith("mailto:")
);
}
function renderParagraphText(text: string) {
const parts = [];
let lastIndex = 0;
for (const match of text.matchAll(paragraphLinkPattern)) {
const [rawText, label, href] = match;
const matchIndex = match.index ?? 0;
if (matchIndex > lastIndex) {
parts.push(text.slice(lastIndex, matchIndex));
}
if (isAllowedParagraphHref(href)) {
const linkClassName =
"text-[#151515]/82 underline decoration-[#8b967a]/45 decoration-1 underline-offset-[5px] transition hover:text-[#5f6f52] hover:decoration-[#5f6f52]";
parts.push(
href.startsWith("/") || href.startsWith("#") ? (
<Link key={`link-${matchIndex}`} href={href} className={linkClassName}>
{label}
</Link>
) : (
<a
key={`link-${matchIndex}`}
href={href}
className={linkClassName}
target="_blank"
rel="noreferrer"
>
{label}
</a>
),
);
} else {
parts.push(rawText);
}
lastIndex = matchIndex + rawText.length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
export function LifeArticlePage({ post, prevPost, nextPost }: LifeArticlePageProps) {
const reduceMotion = useReducedMotion();
const distance = reduceMotion ? 0 : 24;
@@ -119,28 +178,51 @@ export function LifeArticlePage({ post, prevPost, nextPost }: LifeArticlePagePro
return (
<p
key={`paragraph-${index}`}
className="text-base leading-8 text-[#151515]/75 sm:text-lg sm:leading-9"
className="whitespace-pre-line text-base leading-8 text-[#151515]/75 sm:text-lg sm:leading-9"
>
{block.text}
{renderParagraphText(block.text)}
</p>
);
}
const showOriginalImage = block.display === "original" && block.width && block.height;
const originalImageAlignClass =
block.align === "center"
? "mx-auto"
: block.align === "right"
? "ml-auto"
: "mr-auto";
return (
<motion.figure
key={`image-${index}`}
variants={scaleReveal}
className="overflow-hidden rounded-[1.5rem] border border-[#151515]/8 bg-white shadow-xl shadow-[#5f6f52]/8"
className={
showOriginalImage
? "w-full"
: "overflow-hidden rounded-[1.5rem] border border-[#151515]/8 bg-white shadow-xl shadow-[#5f6f52]/8"
}
>
<div className="relative aspect-[16/9] w-full overflow-hidden bg-[#e7e8df]">
{showOriginalImage ? (
<Image
src={block.src}
alt={block.alt}
fill
width={block.width}
height={block.height}
sizes="(max-width: 768px) 100vw, 896px"
className="object-cover"
className={`${originalImageAlignClass} h-auto max-w-full rounded-[1rem]`}
/>
</div>
) : (
<div className="relative aspect-[16/9] w-full overflow-hidden bg-[#e7e8df]">
<Image
src={block.src}
alt={block.alt}
fill
sizes="(max-width: 768px) 100vw, 896px"
className="object-cover"
/>
</div>
)}
{block.caption ? (
<figcaption className="px-5 py-4 text-sm leading-6 text-[#151515]/55 sm:px-6">
{block.caption}
+128
View File
@@ -0,0 +1,128 @@
# 日常分享内容说明
这个目录放 `/life` 下的日常分享文章。每篇文章是一个 `LifePost` 对象,字段定义在 `types.ts`
## 文章字段
```ts
export const demoPost = {
slug: "demo-post",
date: "2026.06.26",
title: "文章标题",
description: "列表页和文章头部显示的简介。",
content: [
{
type: "paragraph",
text: "一段正文。",
},
],
} satisfies LifePost;
```
- `slug`: 文章地址的一部分,例如 `demo-post` 会生成 `/life/demo-post`
- `date`: 文章日期,格式用 `YYYY.MM.DD`。列表页会按这个字段倒序排序。
- `title`: 文章标题。
- `description`: 文章简介,会显示在列表页、精选文章和文章头部。
- `content`: 文章正文块数组。现在支持段落和图片,可以按阅读顺序穿插。
## 段落块
```ts
{
type: "paragraph",
text: "这里是一段正文。",
}
```
- `type`: 固定写 `"paragraph"`
- `text`: 正文内容。一个对象对应一个段落。
如果某一段内部需要手动换行,可以在 `text` 里写 `\n`
```ts
{
type: "paragraph",
text: "第一行\n第二行\n第三行",
}
```
如果正文里需要链接,可以用 Markdown 风格:
```ts
{
type: "paragraph",
text: "参考这篇文章:[Giffgaff eSIM 直装版](https://simonmy.com/posts/giffgaff-esim-mod-apk.html)",
}
```
支持站内路径、锚点、`http(s)``mailto:`。外部链接会新开标签。
## 图片块
```ts
{
type: "image",
src: "/life/demo/image.jpg",
alt: "图片说明",
}
```
- `type`: 固定写 `"image"`
- `src`: 图片路径。放在 `public` 里的图片,路径从 `/` 开始写。
- `alt`: 图片替代文本,用于无障碍和图片加载失败时展示。
- `caption`: 可选。图片下方的图注,不想显示图注就不写。
- `display`: 可选。图片展示方式,支持 `"cover"``"original"`
- `align`: 可选。原图模式下的对齐方式,支持 `"left"``"center"``"right"`,默认左对齐。
- `width` / `height`: 可选。原图模式建议填写原始像素尺寸,避免页面加载时跳动。
## 图片展示方式
### 封面模式
```ts
{
type: "image",
src: "/life/demo/cover.jpg",
alt: "横向封面图",
display: "cover",
}
```
`cover` 会把图片放进固定比例的展示框里,适合横向照片、封面图、氛围图。图片会铺满容器,必要时会被裁切。
不写 `display` 时,默认也按封面模式显示,兼容旧文章。
### 原图模式
```ts
{
type: "image",
src: "/life/demo/screenshot.jpg",
alt: "手机截图",
display: "original",
align: "left",
width: 278,
height: 472,
}
```
`original` 会按图片原始比例和尺寸展示,不加背景框、边框和阴影。图片超过正文宽度时才会缩小。适合手机截图、软件界面截图、聊天记录截图这类不想被裁切的图片。
`align` 只在原图模式下生效:
- `"left"`: 左对齐,也是默认值。
- `"center"`: 居中。
- `"right"`: 右对齐。
## 封面图规则
列表页和精选文章会把文章里的第一张图片当封面图。封面区域始终按封面方式显示,所以即使正文里这张图是 `display: "original"`,列表封面也会裁切展示。
如果某篇文章没有图片,列表页不会显示图片卡槽,只显示文字内容。
## 常用建议
- 手机截图、步骤截图:用 `display: "original"`,并填写 `width``height`
- 横向照片、活动图、封面图:用 `display: "cover"` 或不写 `display`
- 不想显示图注:不要写 `caption`
- 新增文章后,把文章对象加到 `posts.ts``allLifePosts` 数组里即可,列表顺序会自动按日期排序。
@@ -0,0 +1,134 @@
import type { LifePost } from "../types";
export const giffgaffEsimWhiteCard = {
slug: "giffgaff-esim-white-card",
date: "2026.06.03",
title: "小白卡写入 giffgaff 的流程",
description: "记录一次把 giffgaff eSIM 写入小白卡的流程,从准备材料到成功出信号。",
content: [
{
type: "paragraph",
text: "准备工作:\n一台安卓手机\n一张能支付外币的visa或者万事达\n一张 euicc 小白卡,pdd购买\n",
},
{
type: "paragraph",
text: "第一步是把小白卡插进安卓手机卡槽,然后安装 EasyEUICC。\n[链接 https://gitea.angry.im/PeterCxy/OpenEUICC/releases](https://gitea.angry.im/PeterCxy/OpenEUICC/releases)\n 安装时如果系统或者安全软件拦截,按实际提示放行就行。打开后先做一次兼容性检查,检查完成之后把 app 从后台关掉再重新打开,右下角展示+则正常。",
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image.jpg",
alt: "EasyEUICC 安装界面",
display: "original",
width: 157,
height: 201,
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image(1).jpg",
alt: "EasyEUICC 主界面",
display: "original",
width: 288,
height: 376,
},
{
type: "paragraph",
text: "第二步是去拿 giffgaff 的 eSIM 信息。需要使用改造版 giffgaff app,打开后先允许需要的权限,再按页面流程接受 cookies、创建新用户、填写邮箱、接收验证码并设置密码。[改造版获取地址 https://t.me/simonlabs](https://t.me/simonlabs)",
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image(2).jpg",
alt: "giffgaff app 初始界面",
display: "original",
width: 398,
height: 477,
},
{
type: "paragraph",
text: "这里遇到过一个比较烦的小坑:填写邮箱后如果手机默认浏览器是系统自带浏览器,可能会卡在登录或者验证环节里反复跳转。我当时的解决办法是装一个第三方浏览器,并在系统设置里把它设为默认浏览器,再回到 giffgaff 流程继续。",
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image.webp",
alt: "giffgaff 邮箱填写界面",
display: "original",
width: 339,
height: 512,
},
{
type: "paragraph",
text: "账号建好后会进入giffgaff首页,选择eSIM这个Tab 点击Choose your plan, 滑动到最下方选择I don't want a plan,选择充值最少的10英镑",
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image(10).jpg",
alt: "giffgaff 套餐确认页面",
display: "original",
width: 337,
height: 485,
},
{
type: "paragraph",
text: "付款前会让你补一段个人信息和银行卡信息。直接让ai生成一个真实的英国街道地址,填完之后继续添加银行卡并付款。",
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image(11).jpg",
alt: "giffgaff eSIM 申请成功页面",
display: "original",
width: 331,
height: 211,
},
{
type: "paragraph",
text: "拿到 eSIM 之后,继续点 install eSIM。这个时候页面会弹出一段用于安装 eSIM 的信息,重点是把弹窗里那段内容复制并保存好,因为后面写入小白卡要用。",
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image(12).webp",
alt: "giffgaff eSIM 安装信息弹窗",
display: "original",
width: 335,
height: 439,
},
{
type: "paragraph",
text: "第三步回到 EasyEUICC 写卡。打开 EasyEUICC 后先从右上角菜单再做一次兼容性检查,确认没问题后进入添加流程,点击右下角的加号,选择手动输入。",
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image(16).jpg",
alt: "EasyEUICC 添加 eSIM 页面",
display: "original",
width: 340,
height: 541,
},
{
type: "paragraph",
text: "手动输入时,刚才复制的那段信息要拆开看:第一个 $ 到第二个 $ 中间那段一般是服务器地址,第二个 $ 后面的部分就是激活码。把对应内容填进去后点击下一步,EasyEUICC 会开始下载 profile。",
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image(18).jpg",
alt: "EasyEUICC 手动输入 eSIM 信息",
display: "original",
width: 312,
height: 407,
},
{
type: "paragraph",
text: "下载完成后,在 EasyEUICC 里点右上角菜单,选择启用。等待一会儿如果能出信号,基本就说明写卡成功了,大概要几分钟时间,收到了 giffgaff 的短信,流程算是完整跑通。",
},
{
type: "image",
src: "/life/giffgaff-esim-white-card/image(20).jpg",
alt: "giffgaff 短信确认",
display: "original",
width: 278,
height: 472,
},
{
type: "paragraph",
text: "注意事项就是我们选择的是最便宜的套餐,没有月租,但是要每半年进行一次余额变动,不然会被停掉,所以每半年发送一次短信即可,扣费0.3英镑,不被封号的情况下,10英镑已经够用十几年了,记得定好闹钟或者每年五一,十一,过年都发送一次短信保号",
},
],
} satisfies LifePost;
+15 -7
View File
@@ -1,5 +1,5 @@
import { nikoMajorChampion } from "./articles/niko-major-champion";
import { countLifePostParagraphs, getLifePostImages } from "./content";
import { lifePosts } from "./posts";
import type { LifeContentBlock, LifePostImage } from "./types";
const inlineContent: LifeContentBlock[] = [
@@ -9,22 +9,30 @@ const inlineContent: LifeContentBlock[] = [
src: "/life/demo/step.jpg",
alt: "Demo step",
caption: "A demo image inside the article.",
display: "original",
align: "center",
width: 320,
height: 240,
},
{ type: "paragraph", text: "second paragraph" },
];
const images: LifePostImage[] = getLifePostImages(nikoMajorChampion);
const paragraphCount: number = countLifePostParagraphs(nikoMajorChampion);
for (const post of lifePosts) {
const images: LifePostImage[] = getLifePostImages(post);
const paragraphCount: number = countLifePostParagraphs(post);
void images;
void paragraphCount;
}
const inlineImages: LifePostImage[] = getLifePostImages({
...nikoMajorChampion,
...lifePosts[0],
content: inlineContent,
});
const inlineParagraphCount: number = countLifePostParagraphs({
...nikoMajorChampion,
...lifePosts[0],
content: inlineContent,
});
void images;
void paragraphCount;
void inlineImages;
void inlineParagraphCount;
+9 -1
View File
@@ -3,7 +3,15 @@ import type { LifePost, LifePostImage } from "./types";
export function getLifePostImages(post: LifePost): LifePostImage[] {
return post.content
.filter((block) => block.type === "image")
.map(({ src, alt, caption }) => ({ src, alt, caption }));
.map(({ src, alt, caption, display, align, width, height }) => ({
src,
alt,
caption,
display,
align,
width,
height,
}));
}
export function getLifePostCoverImage(post: LifePost): LifePostImage | undefined {
+10 -1
View File
@@ -1,7 +1,16 @@
import { giffgaffEsimWhiteCard } from "./articles/giffgaff-esim-white-card";
import { nikoMajorChampion } from "./articles/niko-major-champion";
import type { LifePost } from "./types";
export const lifePosts = [nikoMajorChampion] as const satisfies readonly LifePost[];
const allLifePosts = [giffgaffEsimWhiteCard, nikoMajorChampion] as const satisfies readonly LifePost[];
function toLifePostTimestamp(date: string) {
return Date.parse(date.replace(/\./g, "-"));
}
export const lifePosts = [...allLifePosts].sort(
(postA, postB) => toLifePostTimestamp(postB.date) - toLifePostTimestamp(postA.date),
) as readonly LifePost[];
export function getLifePost(slug: string) {
return lifePosts.find((post) => post.slug === slug);
+4
View File
@@ -2,6 +2,10 @@ export type LifePostImage = {
src: string;
alt: string;
caption?: string;
display?: "cover" | "original";
align?: "left" | "center" | "right";
width?: number;
height?: number;
};
export type LifeContentBlock =