release(ios): 发布 0.2.1 并重构图标与界面细节
- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读 - 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失 - 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升 - 详情头部指标精简为 4 行,数据模块改为单条读数 - 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt - 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
+37
-19
@@ -1,26 +1,44 @@
|
||||
<!-- 设计参考稿,构图与 Design/generate_app_icon.swift 一致;构建实际用 Swift 脚本光栅化 1024 PNG。 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="gold" x1="0.18" y1="0.04" x2="0.82" y2="0.96">
|
||||
<stop offset="0" stop-color="#FFE9B0"/>
|
||||
<stop offset="0.24" stop-color="#F4C767"/>
|
||||
<stop offset="0.68" stop-color="#DA9F45"/>
|
||||
<stop offset="1" stop-color="#B77B2D"/>
|
||||
<linearGradient id="field" x1="0.10" y1="0" x2="0.92" y2="1">
|
||||
<stop offset="0" stop-color="#1C4280"/>
|
||||
<stop offset="0.46" stop-color="#0D2450"/>
|
||||
<stop offset="1" stop-color="#040B1B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="0.18" y1="0" x2="0.82" y2="0.88">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.24"/>
|
||||
<stop offset="0.52" stop-color="#FFFFFF" stop-opacity="0.04"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
<radialGradient id="key" cx="0.26" cy="-0.04" r="0.92">
|
||||
<stop offset="0" stop-color="#96C4FF" stop-opacity="0.25"/>
|
||||
<stop offset="0.55" stop-color="#70A2E6" stop-opacity="0.08"/>
|
||||
<stop offset="1" stop-color="#70A2E6" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="vig" cx="0.5" cy="0.52" r="0.78">
|
||||
<stop offset="0.55" stop-color="#020712" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#020712" stop-opacity="0.59"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#FFF4CD"/>
|
||||
<stop offset="0.30" stop-color="#FACE6A"/>
|
||||
<stop offset="0.62" stop-color="#D69628"/>
|
||||
<stop offset="1" stop-color="#925C10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2A2F37"/>
|
||||
<stop offset="1" stop-color="#111419"/>
|
||||
<linearGradient id="slot" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#050D1E"/>
|
||||
<stop offset="0.58" stop-color="#091732"/>
|
||||
<stop offset="1" stop-color="#112649"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1024" height="1024" fill="url(#gold)"/>
|
||||
<rect width="1024" height="1024" fill="url(#sheen)"/>
|
||||
<g fill="url(#bar)">
|
||||
<rect x="317" y="602" width="108" height="188" rx="54"/>
|
||||
<rect x="458" y="475" width="108" height="315" rx="54"/>
|
||||
<rect x="599" y="348" width="108" height="442" rx="54"/>
|
||||
</g>
|
||||
|
||||
<rect width="1024" height="1024" fill="url(#field)"/>
|
||||
<rect width="1024" height="1024" fill="url(#key)"/>
|
||||
<rect width="1024" height="1024" fill="url(#vig)"/>
|
||||
|
||||
<!-- SIM 卡体:左上切角,其余三角大圆角 -->
|
||||
<path d="M494.5,218 L688,218 Q822,218 822,352 L822,704 Q822,838 688,838 L336,838
|
||||
Q202,838 202,704 L202,510.5 Q202,420 266,356 L340,282 Q404,218 494.5,218 Z"
|
||||
fill="url(#gold)"/>
|
||||
|
||||
<!-- 三格信号:底边对齐,右高,读作多卡队列健康度 -->
|
||||
<rect x="318" y="546" width="88" height="142" rx="26" fill="url(#slot)"/>
|
||||
<rect x="468" y="446" width="88" height="242" rx="26" fill="url(#slot)"/>
|
||||
<rect x="618" y="346" width="88" height="342" rx="26" fill="url(#slot)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,276 @@
|
||||
// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only.
|
||||
// Usage: swift Design/generate_app_icon.swift <output.png>
|
||||
//
|
||||
// The mark is the same geometry as the web favicon: a gold SIM plate carrying
|
||||
// three ascending signal bars. One flat silhouette, one light source, and no
|
||||
// photographic detail, so it still reads at the 29pt settings size. The plate
|
||||
// keeps the classic SIM chamfer, moved to the top-left so the tallest bar can
|
||||
// sit under the intact top-right corner.
|
||||
//
|
||||
// Every coordinate is in visual space (origin top-left, y grows down).
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
let side: CGFloat = 1024
|
||||
let output = CommandLine.arguments.count > 1
|
||||
? CommandLine.arguments[1]
|
||||
: "Design/AppIcon.png"
|
||||
|
||||
func envDouble(_ name: String, _ fallback: Double) -> CGFloat {
|
||||
guard let raw = ProcessInfo.processInfo.environment[name], let value = Double(raw) else {
|
||||
return CGFloat(fallback)
|
||||
}
|
||||
return CGFloat(value)
|
||||
}
|
||||
|
||||
func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: Int = 255) -> CGColor {
|
||||
CGColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
|
||||
}
|
||||
|
||||
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
|
||||
let ctx = CGContext(
|
||||
data: nil, width: Int(side), height: Int(side), bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else {
|
||||
FileHandle.standardError.write("cannot create bitmap context\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
ctx.translateBy(x: 0, y: side)
|
||||
ctx.scaleBy(x: 1, y: -1)
|
||||
|
||||
typealias Stop = (location: CGFloat, color: (Int, Int, Int, Int))
|
||||
|
||||
func makeGradient(_ stops: [Stop]) -> CGGradient? {
|
||||
var components: [CGFloat] = []
|
||||
for stop in stops {
|
||||
components.append(CGFloat(stop.color.0) / 255)
|
||||
components.append(CGFloat(stop.color.1) / 255)
|
||||
components.append(CGFloat(stop.color.2) / 255)
|
||||
components.append(CGFloat(stop.color.3) / 255)
|
||||
}
|
||||
let locations = stops.map(\.location)
|
||||
return CGGradient(
|
||||
colorSpace: space,
|
||||
colorComponents: components,
|
||||
locations: locations,
|
||||
count: stops.count
|
||||
)
|
||||
}
|
||||
|
||||
func fillLinear(_ path: CGPath, _ stops: [Stop], _ start: CGPoint, _ end: CGPoint) {
|
||||
guard let gradient = makeGradient(stops) else { return }
|
||||
ctx.saveGState()
|
||||
ctx.addPath(path)
|
||||
ctx.clip()
|
||||
ctx.drawLinearGradient(
|
||||
gradient, start: start, end: end,
|
||||
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
|
||||
)
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
func fillRadial(_ center: CGPoint, _ radius: CGFloat, _ stops: [Stop]) {
|
||||
guard let gradient = makeGradient(stops) else { return }
|
||||
ctx.saveGState()
|
||||
ctx.drawRadialGradient(
|
||||
gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius,
|
||||
options: [.drawsAfterEndLocation]
|
||||
)
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
func rectPath(_ rect: CGRect) -> CGPath { CGPath(rect: rect, transform: nil) }
|
||||
|
||||
func roundedPath(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
|
||||
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
||||
}
|
||||
|
||||
/// SIM plate outline: rounded square with one cut top corner.
|
||||
func platePath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath {
|
||||
let bevel = corner * 0.28
|
||||
let vertices: [(point: CGPoint, radius: CGFloat)] = [
|
||||
(CGPoint(x: rect.minX + chamfer, y: rect.minY), bevel),
|
||||
(CGPoint(x: rect.maxX, y: rect.minY), corner),
|
||||
(CGPoint(x: rect.maxX, y: rect.maxY), corner),
|
||||
(CGPoint(x: rect.minX, y: rect.maxY), corner),
|
||||
(CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel),
|
||||
]
|
||||
let path = CGMutablePath()
|
||||
path.move(to: vertices[0].point)
|
||||
for index in vertices.indices {
|
||||
path.addArc(
|
||||
tangent1End: vertices[index].point,
|
||||
tangent2End: vertices[(index + 1) % vertices.count].point,
|
||||
radius: vertices[index].radius
|
||||
)
|
||||
}
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
|
||||
/// Bright along the upper-left edges, dead by the lower-right: an even outline
|
||||
/// reads like a sticker cut rather than a plate lit from above.
|
||||
func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [Stop]) {
|
||||
ctx.saveGState()
|
||||
ctx.addPath(path)
|
||||
ctx.clip()
|
||||
ctx.addPath(path)
|
||||
ctx.setLineWidth(width)
|
||||
ctx.replacePathWithStrokedPath()
|
||||
ctx.clip()
|
||||
fillLinear(rectPath(rect.insetBy(dx: -8, dy: -8)), stops,
|
||||
CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
// MARK: - Field
|
||||
|
||||
fillLinear(
|
||||
rectPath(CGRect(x: 0, y: 0, width: side, height: side)),
|
||||
[
|
||||
(0.00, (28, 66, 128, 255)),
|
||||
(0.46, (13, 36, 80, 255)),
|
||||
(1.00, (4, 11, 27, 255)),
|
||||
] as [Stop],
|
||||
CGPoint(x: side * 0.10, y: 0),
|
||||
CGPoint(x: side * 0.92, y: side)
|
||||
)
|
||||
fillRadial(
|
||||
CGPoint(x: side * 0.26, y: -side * 0.04), CGFloat(side * 0.92),
|
||||
[(0.0, (150, 196, 255, 64)), (0.55, (112, 162, 230, 20)), (1.0, (112, 162, 230, 0))] as [Stop]
|
||||
)
|
||||
fillRadial(
|
||||
CGPoint(x: side * 0.5, y: side * 0.52), CGFloat(side * 0.78),
|
||||
[(0.55, (2, 7, 18, 0)), (1.0, (2, 7, 18, 150))] as [Stop]
|
||||
)
|
||||
|
||||
// MARK: - Plate
|
||||
|
||||
let plateSide = envDouble("ICON_PLATE_SIDE", 620)
|
||||
let plateCenterY = envDouble("ICON_PLATE_Y", 528)
|
||||
let plateCorner = envDouble("ICON_PLATE_CORNER", 134)
|
||||
let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 202)
|
||||
let plateRect = CGRect(
|
||||
x: (side - plateSide) / 2,
|
||||
y: plateCenterY - plateSide / 2,
|
||||
width: plateSide,
|
||||
height: plateSide
|
||||
)
|
||||
let plate = platePath(plateRect, corner: plateCorner, chamfer: plateChamfer)
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: -30), blur: 62, color: rgb(2, 8, 20, 168))
|
||||
ctx.addPath(plate)
|
||||
ctx.setFillColor(rgb(0, 0, 0))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
fillLinear(
|
||||
plate,
|
||||
[
|
||||
(0.00, (255, 244, 205, 255)),
|
||||
(0.30, (250, 206, 106, 255)),
|
||||
(0.62, (214, 150, 40, 255)),
|
||||
(1.00, (146, 92, 16, 255)),
|
||||
] as [Stop],
|
||||
CGPoint(x: plateRect.minX, y: plateRect.minY),
|
||||
CGPoint(x: plateRect.maxX, y: plateRect.maxY)
|
||||
)
|
||||
|
||||
directionalRim(
|
||||
plate, plateRect, plateSide * 0.016,
|
||||
[
|
||||
(0.00, (255, 251, 232, 230)),
|
||||
(0.40, (255, 240, 190, 66)),
|
||||
(0.74, (255, 232, 170, 0)),
|
||||
] as [Stop]
|
||||
)
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.addPath(plate)
|
||||
ctx.clip()
|
||||
ctx.move(to: CGPoint(x: plateRect.minX - 40, y: plateRect.minY + plateSide * 0.52))
|
||||
ctx.addLine(to: CGPoint(x: plateRect.maxX + 40, y: plateRect.minY + plateSide * 0.10))
|
||||
ctx.addLine(to: CGPoint(x: plateRect.maxX + 40, y: plateRect.minY + plateSide * 0.22))
|
||||
ctx.addLine(to: CGPoint(x: plateRect.minX - 40, y: plateRect.minY + plateSide * 0.64))
|
||||
ctx.closePath()
|
||||
ctx.setFillColor(rgb(255, 252, 236, 5))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
// MARK: - Signal bars
|
||||
|
||||
let barWidth = envDouble("ICON_BAR_W", 88)
|
||||
let barGap = envDouble("ICON_BAR_GAP", 62)
|
||||
let barBottom = envDouble("ICON_BAR_BOTTOM", 688)
|
||||
let barHeights: [CGFloat] = [142, 242, 342]
|
||||
let barsWidth = CGFloat(barHeights.count) * barWidth + CGFloat(barHeights.count - 1) * barGap
|
||||
let barsStartX = (side - barsWidth) / 2
|
||||
|
||||
for (index, height) in barHeights.enumerated() {
|
||||
let rect = CGRect(
|
||||
x: barsStartX + CGFloat(index) * (barWidth + barGap),
|
||||
y: barBottom - height,
|
||||
width: barWidth,
|
||||
height: height
|
||||
)
|
||||
let radius = barWidth * 0.30
|
||||
let path = roundedPath(rect, radius)
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: 5), blur: 16, color: rgb(255, 236, 176, 104))
|
||||
ctx.addPath(path)
|
||||
ctx.setFillColor(rgb(0, 0, 0))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
fillLinear(
|
||||
path,
|
||||
[
|
||||
(0.00, (5, 13, 30, 255)),
|
||||
(0.58, (9, 23, 50, 255)),
|
||||
(1.00, (17, 38, 74, 255)),
|
||||
] as [Stop],
|
||||
CGPoint(x: rect.minX, y: rect.minY),
|
||||
CGPoint(x: rect.minX, y: rect.maxY)
|
||||
)
|
||||
|
||||
directionalRim(
|
||||
path, rect, radius * 0.34,
|
||||
[
|
||||
(0.00, (0, 0, 0, 120)),
|
||||
(0.48, (0, 0, 0, 26)),
|
||||
(0.84, (255, 226, 152, 70)),
|
||||
(1.00, (255, 236, 176, 118)),
|
||||
] as [Stop]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Surface sheen
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.addPath(rectPath(CGRect(x: 0, y: 0, width: side, height: side)))
|
||||
ctx.clip()
|
||||
ctx.move(to: CGPoint(x: -120, y: 430))
|
||||
ctx.addLine(to: CGPoint(x: 1140, y: 40))
|
||||
ctx.addLine(to: CGPoint(x: 1140, y: 214))
|
||||
ctx.addLine(to: CGPoint(x: -120, y: 604))
|
||||
ctx.closePath()
|
||||
ctx.setFillColor(rgb(255, 255, 255, 4))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
guard let image = ctx.makeImage() else {
|
||||
FileHandle.standardError.write("cannot finalize image\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let rep = NSBitmapImageRep(cgImage: image)
|
||||
guard let data = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("cannot encode png\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
try! data.write(to: URL(fileURLWithPath: output))
|
||||
print(output)
|
||||
Reference in New Issue
Block a user